Compare commits
10 Commits
02755bd1f0
...
a814dd3387
| Author | SHA1 | Date | |
|---|---|---|---|
| a814dd3387 | |||
| 36dce50b64 | |||
| 7e99a053da | |||
| b4ab9c6cf9 | |||
| bc6c01940a | |||
| ed83c651d8 | |||
| 7ce1c2a8bc | |||
| 6931670a1f | |||
| cc8ab2320b | |||
| 08cca68086 |
@@ -12,8 +12,26 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/data/lck.db
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
portal:
|
||||
build:
|
||||
context: ../lck-control-portal
|
||||
dockerfile: Dockerfile
|
||||
container_name: lck-control-portal
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3200:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=https://lck.omigame.dev
|
||||
- HOSTNAME=0.0.0.0
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
376
package-lock.json
generated
376
package-lock.json
generated
@@ -10,21 +10,42 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.2.1",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@prisma/client": "^6.4.1",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"jose": "^6.0.8",
|
||||
"undici": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"prisma": "^6.4.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@derhuerst/http-basic": {
|
||||
"version": "8.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caseless": "^0.12.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"http-response-object": "^3.0.1",
|
||||
"parse-cache-control": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@@ -488,6 +509,12 @@
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
|
||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
|
||||
@@ -528,6 +555,22 @@
|
||||
"mnemonist": "0.40.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/deepmerge": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
|
||||
"integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
@@ -598,6 +641,29 @@
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/multipart": {
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz",
|
||||
"integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^3.0.0",
|
||||
"@fastify/deepmerge": "^3.0.0",
|
||||
"@fastify/error": "^4.0.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"secure-json-parse": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/proxy-addr": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||
@@ -639,6 +705,27 @@
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexify": "^4.1.3",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -1128,6 +1215,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/fluent-ffmpeg": {
|
||||
"version": "2.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz",
|
||||
"integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
@@ -1138,6 +1235,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
@@ -1259,6 +1366,18 @@
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
@@ -1302,6 +1421,11 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
|
||||
"integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -1331,6 +1455,12 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
@@ -1370,6 +1500,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
@@ -1423,6 +1559,21 @@
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
@@ -1457,7 +1608,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -1527,6 +1677,18 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1",
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.18.4",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||
@@ -1548,6 +1710,24 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
@@ -1784,6 +1964,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@derhuerst/http-basic": "^8.2.0",
|
||||
"env-paths": "^2.2.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"progress": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
|
||||
@@ -1798,6 +1994,20 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fluent-ffmpeg": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
|
||||
"integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^0.2.9",
|
||||
"which": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1844,6 +2054,40 @@
|
||||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^10.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object/node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||
@@ -1853,6 +2097,12 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -1971,7 +2221,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -2047,6 +2296,20 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-cache-control": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -2211,6 +2474,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
@@ -2245,6 +2517,20 @@
|
||||
"destr": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -2357,6 +2643,26 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
|
||||
@@ -2468,6 +2774,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stream-shift": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
@@ -2586,6 +2907,12 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2616,6 +2943,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
@@ -2794,6 +3127,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"which": "bin/which"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -2810,6 +3155,33 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,24 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.2.1",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@prisma/client": "^6.4.1",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"jose": "^6.0.8",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"undici": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/ws": "^8.18.1",
|
||||
"prisma": "^6.4.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Follow" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"followerId" TEXT NOT NULL,
|
||||
"followingId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Follow_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Follow_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Like" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Like_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Like_planId_fkey" FOREIGN KEY ("planId") REFERENCES "StreamPlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"metaId" TEXT,
|
||||
"facebookId" TEXT,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"avatarUrl" TEXT,
|
||||
"bio" TEXT NOT NULL DEFAULT '',
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_User" ("avatarUrl", "createdAt", "displayName", "email", "id", "metaId", "updatedAt") SELECT "avatarUrl", "createdAt", "displayName", "email", "id", "metaId", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_metaId_key" ON "User"("metaId");
|
||||
CREATE UNIQUE INDEX "User_facebookId_key" ON "User"("facebookId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Follow_followerId_idx" ON "Follow"("followerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Follow_followingId_idx" ON "Follow"("followingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Follow_followerId_followingId_key" ON "Follow"("followerId", "followingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Like_planId_idx" ON "Like"("planId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Like_userId_planId_key" ON "Like"("userId", "planId");
|
||||
@@ -13,12 +13,32 @@ model User {
|
||||
displayName String
|
||||
email String?
|
||||
avatarUrl String?
|
||||
bio String @default("")
|
||||
isPublic Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
linkedAccounts LinkedAccount[]
|
||||
streamPlans StreamPlan[]
|
||||
sessions Session[]
|
||||
pairingCodes PairingCode[]
|
||||
followers Follow[] @relation("following")
|
||||
following Follow[] @relation("follower")
|
||||
likes Like[]
|
||||
devices Device[]
|
||||
videos Video[]
|
||||
videoLikes VideoLike[]
|
||||
}
|
||||
|
||||
model PairingCode {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
@@ -61,9 +81,11 @@ model StreamPlan {
|
||||
status String @default("DRAFT")
|
||||
executionMode String @default("IN_GAME")
|
||||
gameId String @default("")
|
||||
isPublic Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
destinations StreamDestination[]
|
||||
likes Like[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -86,3 +108,80 @@ model StreamDestination {
|
||||
|
||||
@@index([planId])
|
||||
}
|
||||
|
||||
model Follow {
|
||||
id String @id @default(uuid())
|
||||
followerId String
|
||||
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
|
||||
followingId String
|
||||
following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([followerId, followingId])
|
||||
@@index([followerId])
|
||||
@@index([followingId])
|
||||
}
|
||||
|
||||
model Like {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
planId String
|
||||
plan StreamPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, planId])
|
||||
@@index([planId])
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
deviceId String
|
||||
deviceName String @default("")
|
||||
deviceType String @default("QUEST") // "QUEST" or "PHONE"
|
||||
isOnline Boolean @default(false)
|
||||
lastSeen DateTime @default(now())
|
||||
batteryLevel Int?
|
||||
storageAvailable Int?
|
||||
runningGame String?
|
||||
streamingState String?
|
||||
cortexState String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, deviceId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Video {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
description String @default("")
|
||||
duration Int @default(0)
|
||||
thumbnailUrl String?
|
||||
videoUrl String
|
||||
fileSize Int?
|
||||
isPublic Boolean @default(true)
|
||||
sourcePlanId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
likes VideoLike[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model VideoLike {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
videoId String
|
||||
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, videoId])
|
||||
@@index([videoId])
|
||||
}
|
||||
|
||||
42
src/app.ts
42
src/app.ts
@@ -1,17 +1,32 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import multipart from '@fastify/multipart';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import websocket from '@fastify/websocket';
|
||||
import prismaPlugin from './plugins/prisma.js';
|
||||
import errorHandlerPlugin from './plugins/error-handler.js';
|
||||
import authPlugin from './plugins/auth.js';
|
||||
import pageRoutes from './routes/pages.js';
|
||||
import healthRoutes from './routes/health.js';
|
||||
import metaAuthRoutes from './routes/auth/meta.js';
|
||||
import sessionRoutes from './routes/auth/session.js';
|
||||
import pairingRoutes from './routes/auth/pairing.js';
|
||||
import accountRoutes from './routes/providers/accounts.js';
|
||||
import youtubeRoutes from './routes/providers/youtube.js';
|
||||
import twitchRoutes from './routes/providers/twitch.js';
|
||||
import planRoutes from './routes/streams/plans.js';
|
||||
import lifecycleRoutes from './routes/streams/lifecycle.js';
|
||||
import previewRoutes from './routes/streams/preview.js';
|
||||
import followingRoutes from './routes/social/following.js';
|
||||
import feedRoutes from './routes/social/feed.js';
|
||||
import likesRoutes from './routes/social/likes.js';
|
||||
import { createChatRoutes } from './routes/chat/websocket.js';
|
||||
import { createSignalingRoutes } from './routes/signaling/websocket.js';
|
||||
import deviceRoutes from './routes/devices/status.js';
|
||||
import videoRoutes from './routes/content/videos.js';
|
||||
import thumbnailRoutes from './routes/media/thumbnails.js';
|
||||
import { ChatManager } from './services/chat-manager.service.js';
|
||||
import { SignalingManager } from './services/signaling-manager.service.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
export async function buildApp() {
|
||||
@@ -21,8 +36,11 @@ export async function buildApp() {
|
||||
},
|
||||
});
|
||||
|
||||
// Plugins
|
||||
await app.register(cors, { origin: config.corsOrigin });
|
||||
// Plugins — support comma-separated CORS origins
|
||||
const corsOrigins = config.corsOrigin === '*'
|
||||
? '*'
|
||||
: config.corsOrigin.split(',').map(s => s.trim());
|
||||
await app.register(cors, { origin: corsOrigins });
|
||||
await app.register(rateLimit, {
|
||||
global: true,
|
||||
max: 100,
|
||||
@@ -31,16 +49,36 @@ export async function buildApp() {
|
||||
await app.register(errorHandlerPlugin);
|
||||
await app.register(prismaPlugin);
|
||||
await app.register(authPlugin);
|
||||
await app.register(websocket);
|
||||
await app.register(multipart);
|
||||
|
||||
// Managers (instantiated after prisma is available)
|
||||
const chatManager = new ChatManager(app.prisma, app.log);
|
||||
const signalingManager = new SignalingManager(app.prisma, app.log);
|
||||
|
||||
// Expose signalingManager for cleanup
|
||||
app.decorate('signalingManager', signalingManager);
|
||||
|
||||
// Routes
|
||||
await app.register(pageRoutes);
|
||||
await app.register(healthRoutes);
|
||||
await app.register(metaAuthRoutes);
|
||||
await app.register(sessionRoutes);
|
||||
await app.register(pairingRoutes);
|
||||
await app.register(accountRoutes);
|
||||
await app.register(youtubeRoutes);
|
||||
await app.register(twitchRoutes);
|
||||
await app.register(planRoutes);
|
||||
await app.register(lifecycleRoutes);
|
||||
await app.register(previewRoutes);
|
||||
await app.register(followingRoutes);
|
||||
await app.register(feedRoutes);
|
||||
await app.register(likesRoutes);
|
||||
await app.register(createChatRoutes(chatManager));
|
||||
await app.register(createSignalingRoutes(signalingManager));
|
||||
await app.register(deviceRoutes);
|
||||
await app.register(videoRoutes);
|
||||
await app.register(thumbnailRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const config = {
|
||||
redirectUri: required('TWITCH_REDIRECT_URI'),
|
||||
},
|
||||
|
||||
portalUrl: optional('PORTAL_URL', 'https://portal.omigame.dev'),
|
||||
appScheme: optional('APP_SCHEME', 'com.omixlab.lckcontrol'),
|
||||
corsOrigin: optional('CORS_ORIGIN', '*'),
|
||||
} as const;
|
||||
|
||||
@@ -17,6 +17,7 @@ async function main() {
|
||||
process.on(signal, async () => {
|
||||
app.log.info(`Received ${signal}, shutting down...`);
|
||||
stopTokenRefreshScheduler();
|
||||
(app as any).signalingManager?.cleanup();
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -14,3 +14,13 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
||||
reply.status(401).send({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function optionalAuth(request: FastifyRequest) {
|
||||
const header = request.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) return;
|
||||
try {
|
||||
request.userId = await verifyAccessToken(header.slice(7));
|
||||
} catch {
|
||||
// not authenticated — continue as anonymous
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ const metaAuthRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
}
|
||||
|
||||
// Upsert user
|
||||
const existingUser = await fastify.prisma.user.findUnique({ where: { metaId } });
|
||||
request.log.info({ metaId, userId: existingUser?.id, isNew: !existingUser }, 'User upsert');
|
||||
const user = await fastify.prisma.user.upsert({
|
||||
where: { metaId },
|
||||
update: { displayName },
|
||||
|
||||
133
src/routes/auth/pairing.ts
Normal file
133
src/routes/auth/pairing.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { randomUUID, randomInt } from 'node:crypto';
|
||||
import { signAccessToken } from '../../plugins/auth.js';
|
||||
import { hashToken } from '../../services/crypto.service.js';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { AuthTokensResponse, PairingGenerateResponse, PairingRedeemBody } from '../../types/api.js';
|
||||
|
||||
const PAIRING_CODE_TTL = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
const pairingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /auth/pairing/generate — create a 6-digit pairing code (requires auth)
|
||||
fastify.post('/auth/pairing/generate', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
// Cleanup expired codes globally
|
||||
await fastify.prisma.pairingCode.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
|
||||
// Delete any existing codes for this user
|
||||
await fastify.prisma.pairingCode.deleteMany({
|
||||
where: { userId: request.userId },
|
||||
});
|
||||
|
||||
// Generate unique 6-digit code
|
||||
let code: string;
|
||||
let attempts = 0;
|
||||
do {
|
||||
code = String(randomInt(100000, 999999));
|
||||
const existing = await fastify.prisma.pairingCode.findUnique({ where: { code } });
|
||||
if (!existing) break;
|
||||
attempts++;
|
||||
} while (attempts < 10);
|
||||
|
||||
if (attempts >= 10) {
|
||||
throw new AppError(500, 'Failed to generate unique pairing code');
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + PAIRING_CODE_TTL);
|
||||
|
||||
await fastify.prisma.pairingCode.create({
|
||||
data: {
|
||||
code,
|
||||
userId: request.userId,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const response: PairingGenerateResponse = {
|
||||
code,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
});
|
||||
|
||||
// GET /auth/pairing/status — check if user's pairing code is still active
|
||||
fastify.get('/auth/pairing/status', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const code = await fastify.prisma.pairingCode.findFirst({
|
||||
where: { userId: request.userId, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
|
||||
reply.status(200).send({
|
||||
active: !!code,
|
||||
code: code?.code ?? null,
|
||||
expiresAt: code?.expiresAt.toISOString() ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /auth/pairing/redeem — exchange a pairing code for tokens (public, rate-limited)
|
||||
fastify.post<{ Body: PairingRedeemBody }>('/auth/pairing/redeem', {
|
||||
config: {
|
||||
rateLimit: { max: 10, timeWindow: '1 minute' },
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: {
|
||||
code: { type: 'string', minLength: 6, maxLength: 6, pattern: '^[0-9]{6}$' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { code } = request.body;
|
||||
|
||||
const pairingCode = await fastify.prisma.pairingCode.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
if (!pairingCode) {
|
||||
throw new AppError(400, 'Invalid pairing code');
|
||||
}
|
||||
|
||||
if (pairingCode.expiresAt < new Date()) {
|
||||
await fastify.prisma.pairingCode.delete({ where: { id: pairingCode.id } });
|
||||
throw new AppError(400, 'Pairing code has expired');
|
||||
}
|
||||
|
||||
// Delete code (single use)
|
||||
await fastify.prisma.pairingCode.delete({ where: { id: pairingCode.id } });
|
||||
|
||||
// Create session
|
||||
const refreshToken = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + config.jwt.refreshTtl * 1000);
|
||||
|
||||
await fastify.prisma.session.create({
|
||||
data: {
|
||||
userId: pairingCode.userId,
|
||||
refreshToken: hashToken(refreshToken),
|
||||
expiresAt,
|
||||
deviceInfo: 'web-portal',
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await signAccessToken(pairingCode.userId);
|
||||
|
||||
const response: AuthTokensResponse = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: config.jwt.accessTtl,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
});
|
||||
};
|
||||
|
||||
export default pairingRoutes;
|
||||
@@ -5,7 +5,7 @@ import { hashToken } from '../../services/crypto.service.js';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { RefreshBody, AuthTokensResponse, UserProfileResponse } from '../../types/api.js';
|
||||
import type { RefreshBody, AuthTokensResponse, UserProfileResponse, UpdateProfileBody } from '../../types/api.js';
|
||||
|
||||
const sessionRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /auth/refresh — rotate refresh token, issue new JWT
|
||||
@@ -83,6 +83,45 @@ const sessionRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
bio: user.bio,
|
||||
isPublic: user.isPublic,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
});
|
||||
|
||||
// PATCH /auth/me — update profile
|
||||
fastify.patch<{ Body: UpdateProfileBody }>('/auth/me', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
displayName: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
bio: { type: 'string', maxLength: 500 },
|
||||
isPublic: { type: 'boolean' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const data: any = {};
|
||||
if (request.body.displayName !== undefined) data.displayName = request.body.displayName;
|
||||
if (request.body.bio !== undefined) data.bio = request.body.bio;
|
||||
if (request.body.isPublic !== undefined) data.isPublic = request.body.isPublic;
|
||||
|
||||
const user = await fastify.prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data,
|
||||
});
|
||||
|
||||
const response: UserProfileResponse = {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
bio: user.bio,
|
||||
isPublic: user.isPublic,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
|
||||
106
src/routes/chat/websocket.ts
Normal file
106
src/routes/chat/websocket.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { verifyAccessToken } from '../../plugins/auth.js';
|
||||
import { ChatManager } from '../../services/chat-manager.service.js';
|
||||
|
||||
interface ChatWsMessage {
|
||||
type: 'subscribe' | 'unsubscribe' | 'send_message' | 'subscribe_portal' | 'send_portal_comment' | 'like';
|
||||
planId?: string;
|
||||
destinationId?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function createChatRoutes(chatManager: ChatManager): FastifyPluginAsync {
|
||||
const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/chat/ws', { websocket: true }, async (socket, request) => {
|
||||
// Authenticate via query param
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Missing token' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await verifyAccessToken(token);
|
||||
} catch {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Invalid or expired token' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
request.log.info({ userId }, 'Chat WebSocket connected');
|
||||
|
||||
socket.on('message', async (data: Buffer) => {
|
||||
try {
|
||||
const raw = data.toString();
|
||||
request.log.info({ userId, raw }, 'Chat WS message received');
|
||||
const msg: ChatWsMessage = JSON.parse(raw);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'subscribe':
|
||||
if (msg.planId) {
|
||||
await chatManager.startChat(msg.planId, userId, socket);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'unsubscribe':
|
||||
if (msg.planId) {
|
||||
await chatManager.stopChat(msg.planId, userId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'send_message':
|
||||
if (msg.planId && msg.destinationId && msg.text) {
|
||||
await chatManager.handleSendMessage(
|
||||
msg.planId,
|
||||
userId,
|
||||
msg.destinationId,
|
||||
msg.text,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'subscribe_portal':
|
||||
if (msg.planId) {
|
||||
await chatManager.subscribePortalChat(msg.planId, userId, socket);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'send_portal_comment':
|
||||
if (msg.planId && msg.text) {
|
||||
await chatManager.handlePortalComment(msg.planId, userId, msg.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'like':
|
||||
if (msg.planId) {
|
||||
await chatManager.handleLike(msg.planId, userId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
socket.send(JSON.stringify({ type: 'error', error: `Unknown message type: ${msg.type}` }));
|
||||
}
|
||||
} catch (err) {
|
||||
request.log.error({ err }, 'Chat WebSocket message handling error');
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Internal error' }));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
request.log.info({ userId }, 'Chat WebSocket disconnected');
|
||||
chatManager.stopAllForSocket(socket);
|
||||
});
|
||||
|
||||
socket.on('error', (err: Error) => {
|
||||
request.log.error({ err, userId }, 'Chat WebSocket error');
|
||||
chatManager.stopAllForSocket(socket);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return chatRoutes;
|
||||
}
|
||||
168
src/routes/content/videos.ts
Normal file
168
src/routes/content/videos.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { optionalAuth } from '../../middleware/require-auth.js';
|
||||
import { createReadStream, existsSync, mkdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { generateMediaAssets } from '../../services/media-processing.service.js';
|
||||
|
||||
const VIDEOS_DIR = join(process.cwd(), 'data', 'videos');
|
||||
|
||||
// Ensure videos directory exists
|
||||
if (!existsSync(VIDEOS_DIR)) {
|
||||
mkdirSync(VIDEOS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const videoRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /content/videos — list videos (public + own)
|
||||
fastify.get<{ Querystring: { cursor?: string; limit?: string } }>('/content/videos', {
|
||||
preHandler: [optionalAuth],
|
||||
}, async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursorId = request.query.cursor;
|
||||
|
||||
const where: any = {
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
...(request.userId ? [{ userId: request.userId }] : []),
|
||||
],
|
||||
};
|
||||
|
||||
const videos = await fastify.prisma.video.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, displayName: true, avatarUrl: true } },
|
||||
_count: { select: { likes: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const hasMore = videos.length > limit;
|
||||
const items = videos.slice(0, limit);
|
||||
|
||||
return {
|
||||
items: items.map(v => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
description: v.description,
|
||||
duration: v.duration,
|
||||
thumbnailUrl: v.thumbnailUrl,
|
||||
videoUrl: `/content/videos/${v.id}/stream`,
|
||||
fileSize: v.fileSize,
|
||||
isPublic: v.isPublic,
|
||||
createdAt: v.createdAt.toISOString(),
|
||||
likeCount: v._count.likes,
|
||||
user: v.user,
|
||||
})),
|
||||
nextCursor: hasMore ? items[items.length - 1].id : null,
|
||||
};
|
||||
});
|
||||
|
||||
// POST /content/videos — upload a video (multipart)
|
||||
fastify.post('/content/videos', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const title = (data.fields.title as any)?.value || 'Untitled';
|
||||
const description = (data.fields.description as any)?.value || '';
|
||||
const duration = parseInt((data.fields.duration as any)?.value || '0', 10);
|
||||
const isPublic = (data.fields.isPublic as any)?.value !== 'false';
|
||||
|
||||
// Create DB record first
|
||||
const video = await fastify.prisma.video.create({
|
||||
data: {
|
||||
userId: request.userId,
|
||||
title,
|
||||
description,
|
||||
duration,
|
||||
videoUrl: '', // will update after save
|
||||
isPublic,
|
||||
},
|
||||
});
|
||||
|
||||
// Save file
|
||||
const filename = `${video.id}.mp4`;
|
||||
const filePath = join(VIDEOS_DIR, filename);
|
||||
await pipeline(data.file, createWriteStream(filePath));
|
||||
|
||||
const stats = statSync(filePath);
|
||||
|
||||
// Update with actual URL and size
|
||||
const updated = await fastify.prisma.video.update({
|
||||
where: { id: video.id },
|
||||
data: {
|
||||
videoUrl: `/content/videos/${video.id}/stream`,
|
||||
fileSize: stats.size,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate thumbnails async (fire-and-forget)
|
||||
generateMediaAssets(filePath, video.id, request.log).catch(() => {});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description,
|
||||
duration: updated.duration,
|
||||
thumbnailUrl: updated.thumbnailUrl,
|
||||
videoUrl: updated.videoUrl,
|
||||
fileSize: updated.fileSize,
|
||||
isPublic: updated.isPublic,
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
// GET /content/videos/:id/stream — stream video with range support
|
||||
fastify.get<{ Params: { id: string } }>('/content/videos/:id/stream', async (request, reply) => {
|
||||
const video = await fastify.prisma.video.findUnique({
|
||||
where: { id: request.params.id },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
return reply.status(404).send({ error: 'Video not found' });
|
||||
}
|
||||
|
||||
const filePath = join(VIDEOS_DIR, `${video.id}.mp4`);
|
||||
if (!existsSync(filePath)) {
|
||||
return reply.status(404).send({ error: 'Video file not found' });
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = request.headers.range;
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
reply
|
||||
.status(206)
|
||||
.header('Content-Range', `bytes ${start}-${end}/${fileSize}`)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Content-Length', chunkSize)
|
||||
.header('Content-Type', 'video/mp4')
|
||||
.header('Cache-Control', 'public, max-age=30');
|
||||
|
||||
return reply.send(createReadStream(filePath, { start, end }));
|
||||
}
|
||||
|
||||
reply
|
||||
.header('Content-Length', fileSize)
|
||||
.header('Content-Type', 'video/mp4')
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Cache-Control', 'public, max-age=30');
|
||||
|
||||
return reply.send(createReadStream(filePath));
|
||||
});
|
||||
};
|
||||
|
||||
export default videoRoutes;
|
||||
73
src/routes/devices/status.ts
Normal file
73
src/routes/devices/status.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
|
||||
const deviceRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /devices — list user's registered devices
|
||||
fastify.get('/devices', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const devices = await fastify.prisma.device.findMany({
|
||||
where: { userId: request.userId },
|
||||
orderBy: { lastSeen: 'desc' },
|
||||
});
|
||||
|
||||
return devices.map(d => ({
|
||||
id: d.id,
|
||||
deviceId: d.deviceId,
|
||||
deviceName: d.deviceName,
|
||||
deviceType: d.deviceType,
|
||||
isOnline: d.isOnline,
|
||||
lastSeen: d.lastSeen.toISOString(),
|
||||
batteryLevel: d.batteryLevel,
|
||||
storageAvailable: d.storageAvailable,
|
||||
runningGame: d.runningGame,
|
||||
streamingState: d.streamingState,
|
||||
cortexState: d.cortexState,
|
||||
}));
|
||||
});
|
||||
|
||||
// GET /devices/:id/status — get specific device status
|
||||
fastify.get<{ Params: { id: string } }>('/devices/:id/status', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const device = await fastify.prisma.device.findFirst({
|
||||
where: { id: request.params.id, userId: request.userId },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return reply.status(404).send({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
deviceId: device.deviceId,
|
||||
deviceName: device.deviceName,
|
||||
deviceType: device.deviceType,
|
||||
isOnline: device.isOnline,
|
||||
lastSeen: device.lastSeen.toISOString(),
|
||||
batteryLevel: device.batteryLevel,
|
||||
storageAvailable: device.storageAvailable,
|
||||
runningGame: device.runningGame,
|
||||
streamingState: device.streamingState,
|
||||
cortexState: device.cortexState,
|
||||
};
|
||||
});
|
||||
|
||||
// DELETE /devices/:id — remove a device
|
||||
fastify.delete<{ Params: { id: string } }>('/devices/:id', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const device = await fastify.prisma.device.findFirst({
|
||||
where: { id: request.params.id, userId: request.userId },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return reply.status(404).send({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
await fastify.prisma.device.delete({ where: { id: device.id } });
|
||||
return { success: true };
|
||||
});
|
||||
};
|
||||
|
||||
export default deviceRoutes;
|
||||
@@ -8,6 +8,22 @@ const healthRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString(), version };
|
||||
});
|
||||
|
||||
// Temporary debug endpoint — remove after diagnosing plan 404 issue
|
||||
fastify.get('/debug/db-state', async () => {
|
||||
const users = await fastify.prisma.user.findMany({
|
||||
select: { id: true, metaId: true, displayName: true },
|
||||
});
|
||||
const plans = await fastify.prisma.streamPlan.findMany({
|
||||
select: { id: true, userId: true, name: true, status: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
const sessions = await fastify.prisma.session.findMany({
|
||||
select: { id: true, userId: true, expiresAt: true, deviceInfo: true },
|
||||
});
|
||||
return { users, plans, sessions };
|
||||
});
|
||||
};
|
||||
|
||||
export default healthRoutes;
|
||||
|
||||
63
src/routes/media/thumbnails.ts
Normal file
63
src/routes/media/thumbnails.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { join } from 'path';
|
||||
import { existsSync, createReadStream, statSync } from 'fs';
|
||||
import { THUMBNAILS_DIR } from '../../services/media-processing.service.js';
|
||||
|
||||
const thumbnailRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /media/thumbnails/:filename — serve thumbnail/poster/clip files
|
||||
fastify.get<{ Params: { filename: string } }>('/media/thumbnails/:filename', {
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: { filename: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { filename } = request.params;
|
||||
|
||||
// Sanitize filename — only allow alphanumeric, hyphens, underscores, dots
|
||||
if (!/^[\w\-.]+$/.test(filename)) {
|
||||
return reply.status(400).send({ error: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filePath = join(THUMBNAILS_DIR, filename);
|
||||
if (!existsSync(filePath)) {
|
||||
return reply.status(404).send({ error: 'Not found' });
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
const contentType = ext === 'jpg' || ext === 'jpeg'
|
||||
? 'image/jpeg'
|
||||
: ext === 'mp4'
|
||||
? 'video/mp4'
|
||||
: 'application/octet-stream';
|
||||
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
|
||||
const range = request.headers.range;
|
||||
if (range && contentType === 'video/mp4') {
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
||||
|
||||
if (start >= stat.size || end >= stat.size || start > end) {
|
||||
reply.status(416).header('Content-Range', `bytes */${stat.size}`).send();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(206);
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${stat.size}`);
|
||||
reply.header('Content-Length', end - start + 1);
|
||||
return reply.send(createReadStream(filePath, { start, end }));
|
||||
}
|
||||
|
||||
return reply.send(createReadStream(filePath));
|
||||
});
|
||||
};
|
||||
|
||||
export default thumbnailRoutes;
|
||||
215
src/routes/pages.ts
Normal file
215
src/routes/pages.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const portalUrl = config.portalUrl || 'https://portal.omigame.dev';
|
||||
|
||||
function layout(title: string, body: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #09090b; color: #fafafa; line-height: 1.6; }
|
||||
.container { max-width: 640px; margin: 0 auto; padding: 48px 20px; }
|
||||
h1 { font-size: 28px; margin-bottom: 8px; }
|
||||
h2 { font-size: 18px; margin: 24px 0 8px; }
|
||||
p, li { font-size: 14px; color: #a1a1aa; }
|
||||
a { color: #3b82f6; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
ul { margin-left: 24px; margin-top: 8px; }
|
||||
li { margin-bottom: 4px; }
|
||||
.subtitle { color: #a1a1aa; font-size: 14px; margin-bottom: 32px; }
|
||||
.section { margin-bottom: 24px; }
|
||||
.footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #27272a; font-size: 12px; color: #52525b; display: flex; gap: 16px; }
|
||||
.btn { display: inline-block; padding: 10px 24px; background: #3b82f6; color: #fff; border-radius: 8px; font-size: 14px; font-weight: 500; }
|
||||
.btn:hover { background: #2563eb; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
${body}
|
||||
<div class="footer">
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
<a href="${portalUrl}">Portal</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const pageRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// Landing page
|
||||
fastify.get('/', async (_request, reply) => {
|
||||
reply.header('content-type', 'text/html; charset=utf-8').send(layout('LCK Control',
|
||||
`<h1>LCK Control</h1>
|
||||
<p class="subtitle">Stream management platform for Meta Quest</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>What is LCK Control?</h2>
|
||||
<p>LCK Control is a live streaming management platform that lets you broadcast gameplay from your Meta Quest headset to YouTube, Twitch, and custom RTMP destinations simultaneously. It consists of:</p>
|
||||
<ul>
|
||||
<li><strong>Companion App</strong> — An Android app on your Quest headset that captures and streams gameplay to multiple platforms at once.</li>
|
||||
<li><strong>Web Portal</strong> — A website at <a href="${portalUrl}">${portalUrl.replace('https://', '')}</a> where you can manage stream plans, link accounts, and discover other users' live streams.</li>
|
||||
<li><strong>Backend API</strong> — This server that handles authentication, stream lifecycle management, and real-time chat integration.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li>Multi-platform streaming — broadcast to YouTube, Twitch, and custom RTMP servers simultaneously.</li>
|
||||
<li>Stream plan management — create, prepare, start, and end streams from your headset or the web portal.</li>
|
||||
<li>Live chat integration — view and respond to YouTube, Twitch, and portal comments in real-time.</li>
|
||||
<li>Discovery feed — browse and watch other users' live streams and VODs in a TikTok-style vertical feed.</li>
|
||||
<li>Social features — follow users, like streams, and leave comments.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<a href="${portalUrl}" class="btn">Open Portal</a>
|
||||
</div>`
|
||||
));
|
||||
});
|
||||
|
||||
// Privacy policy
|
||||
fastify.get('/privacy', async (_request, reply) => {
|
||||
reply.header('content-type', 'text/html; charset=utf-8').send(layout('Privacy Policy — LCK Control',
|
||||
`<h1>Privacy Policy</h1>
|
||||
<p class="subtitle">Last updated: March 2, 2026</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Information We Collect</h2>
|
||||
<p>When you use LCK Control, we may collect the following information:</p>
|
||||
<ul>
|
||||
<li><strong>Account information:</strong> Your name, profile picture, and email address from your Meta (Facebook) or Meta Quest account when you log in.</li>
|
||||
<li><strong>Linked service data:</strong> When you connect YouTube or Twitch accounts, we store access tokens to manage streams on your behalf. We do not store your passwords.</li>
|
||||
<li><strong>Usage data:</strong> Stream plans you create, likes, follows, and comments you make on the platform.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. How We Use Your Information</h2>
|
||||
<ul>
|
||||
<li>To authenticate you and provide access to the platform.</li>
|
||||
<li>To create and manage live streams on YouTube and Twitch on your behalf.</li>
|
||||
<li>To display your public profile and live streams to other users (when you opt in to public discovery).</li>
|
||||
<li>To enable social features such as likes, comments, and follows.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Data Sharing</h2>
|
||||
<p>We do not sell your personal information. We share data only with:</p>
|
||||
<ul>
|
||||
<li><strong>YouTube API Services:</strong> To manage your live broadcasts. YouTube API Services are subject to <a href="https://policies.google.com/privacy">Google's Privacy Policy</a>.</li>
|
||||
<li><strong>Twitch API:</strong> To manage your Twitch streams.</li>
|
||||
<li><strong>Meta Platform:</strong> For authentication purposes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Data Storage & Security</h2>
|
||||
<p>Your data is stored on our secure servers. Access tokens for linked services are encrypted at rest using AES-256-GCM. We retain your data as long as your account is active.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Your Rights</h2>
|
||||
<p>You can:</p>
|
||||
<ul>
|
||||
<li>Unlink any connected service (YouTube, Twitch) at any time from the Accounts page.</li>
|
||||
<li>Toggle your public visibility on or off.</li>
|
||||
<li>Request deletion of your account and all associated data by contacting us.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Google API Services Disclosure</h2>
|
||||
<p>LCK Control's use and transfer to any other app of information received from Google APIs will adhere to the <a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>, including the Limited Use requirements.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Contact</h2>
|
||||
<p>If you have questions about this privacy policy, please contact us at <a href="mailto:omar@omigame.dev">omar@omigame.dev</a>.</p>
|
||||
</div>`
|
||||
));
|
||||
});
|
||||
|
||||
// Terms of service
|
||||
fastify.get('/terms', async (_request, reply) => {
|
||||
reply.header('content-type', 'text/html; charset=utf-8').send(layout('Terms of Service — LCK Control',
|
||||
`<h1>Terms of Service</h1>
|
||||
<p class="subtitle">Last updated: March 2, 2026</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Acceptance of Terms</h2>
|
||||
<p>By accessing or using LCK Control ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Description of Service</h2>
|
||||
<p>LCK Control is a live streaming management platform that allows you to broadcast gameplay from your Meta Quest headset to multiple platforms (YouTube, Twitch) simultaneously, discover other users' live streams, and interact through likes and comments.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Account & Access</h2>
|
||||
<ul>
|
||||
<li>You must log in with a valid Meta (Facebook) or Meta Quest account to access authenticated features.</li>
|
||||
<li>You are responsible for maintaining the security of your account.</li>
|
||||
<li>You must not share your account or use the Service on behalf of others without authorization.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Linked Services</h2>
|
||||
<p>When you link YouTube or Twitch accounts, you authorize LCK Control to manage streams on your behalf. You remain subject to the terms of service of those platforms. You can unlink any service at any time.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. User Conduct</h2>
|
||||
<p>You agree not to:</p>
|
||||
<ul>
|
||||
<li>Use the Service for any unlawful purpose.</li>
|
||||
<li>Stream content that violates the terms of YouTube, Twitch, or applicable law.</li>
|
||||
<li>Harass, abuse, or post harmful content in comments.</li>
|
||||
<li>Attempt to gain unauthorized access to the Service or its systems.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Content & Intellectual Property</h2>
|
||||
<p>You retain ownership of the content you stream. By making your streams public on the portal, you grant other users the ability to view, like, and comment on them. We do not claim ownership of your content.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Disclaimer of Warranties</h2>
|
||||
<p>The Service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation. Stream management depends on third-party APIs (YouTube, Twitch) which may have their own limitations.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>8. Limitation of Liability</h2>
|
||||
<p>To the maximum extent permitted by law, we shall not be liable for any indirect, incidental, or consequential damages arising from your use of the Service, including but not limited to failed streams, data loss, or service interruptions.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>9. Termination</h2>
|
||||
<p>We may suspend or terminate your access to the Service at any time for violation of these terms. You may stop using the Service at any time by unlinking your accounts and discontinuing use.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>10. Changes to Terms</h2>
|
||||
<p>We may update these terms from time to time. Continued use of the Service after changes constitutes acceptance of the updated terms.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>11. Contact</h2>
|
||||
<p>If you have questions about these terms, please contact us at <a href="mailto:omar@omigame.dev">omar@omigame.dev</a>.</p>
|
||||
</div>`
|
||||
));
|
||||
});
|
||||
};
|
||||
|
||||
export default pageRoutes;
|
||||
@@ -1,13 +1,14 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { decrypt } from '../../services/crypto.service.js';
|
||||
import { decrypt, encrypt } from '../../services/crypto.service.js';
|
||||
import { revokeYouTubeToken } from '../../services/youtube.service.js';
|
||||
import { revokeTwitchToken } from '../../services/twitch.service.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { LinkedAccountResponse } from '../../types/api.js';
|
||||
import type { LinkedAccountResponse, CreateCustomRtmpBody } from '../../types/api.js';
|
||||
|
||||
const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /providers/accounts — list linked accounts (no tokens)
|
||||
// GET /providers/accounts — list linked accounts (no tokens, except CUSTOM_RTMP)
|
||||
fastify.get('/providers/accounts', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
@@ -15,17 +16,73 @@ const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
where: { userId: request.userId },
|
||||
});
|
||||
|
||||
const response: LinkedAccountResponse[] = accounts.map((a) => ({
|
||||
id: a.id,
|
||||
serviceId: a.serviceId,
|
||||
displayName: a.displayName,
|
||||
accountId: a.accountId,
|
||||
avatarUrl: a.avatarUrl,
|
||||
}));
|
||||
const response: LinkedAccountResponse[] = accounts.map((a) => {
|
||||
const base: LinkedAccountResponse = {
|
||||
id: a.id,
|
||||
serviceId: a.serviceId,
|
||||
displayName: a.displayName,
|
||||
accountId: a.accountId,
|
||||
avatarUrl: a.avatarUrl,
|
||||
};
|
||||
if (a.serviceId === 'CUSTOM_RTMP') {
|
||||
base.rtmpUrl = decrypt(a.accessTokenEnc, a.accessTokenIv);
|
||||
base.streamKey = decrypt(a.refreshTokenEnc, a.refreshTokenIv);
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
// POST /providers/accounts/custom-rtmp — create a custom RTMP account
|
||||
fastify.post<{ Body: CreateCustomRtmpBody }>('/providers/accounts/custom-rtmp', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['displayName', 'rtmpUrl', 'streamKey'],
|
||||
properties: {
|
||||
displayName: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
rtmpUrl: { type: 'string', minLength: 1, maxLength: 500 },
|
||||
streamKey: { type: 'string', minLength: 1, maxLength: 500 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { displayName, rtmpUrl, streamKey } = request.body;
|
||||
|
||||
const urlEnc = encrypt(rtmpUrl);
|
||||
const keyEnc = encrypt(streamKey);
|
||||
|
||||
const account = await fastify.prisma.linkedAccount.create({
|
||||
data: {
|
||||
userId: request.userId,
|
||||
serviceId: 'CUSTOM_RTMP',
|
||||
accountId: randomUUID(),
|
||||
displayName,
|
||||
avatarUrl: null,
|
||||
accessTokenEnc: urlEnc.ciphertext,
|
||||
accessTokenIv: urlEnc.iv,
|
||||
refreshTokenEnc: keyEnc.ciphertext,
|
||||
refreshTokenIv: keyEnc.iv,
|
||||
tokenExpiresAt: new Date('2099-12-31T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const response: LinkedAccountResponse = {
|
||||
id: account.id,
|
||||
serviceId: account.serviceId,
|
||||
displayName: account.displayName,
|
||||
accountId: account.accountId,
|
||||
avatarUrl: account.avatarUrl,
|
||||
rtmpUrl,
|
||||
streamKey,
|
||||
};
|
||||
|
||||
reply.status(201).send(response);
|
||||
});
|
||||
|
||||
// DELETE /providers/accounts/:id — revoke tokens and unlink by account ID
|
||||
fastify.delete<{ Params: { id: string } }>('/providers/accounts/:id', {
|
||||
preHandler: [requireAuth],
|
||||
@@ -50,16 +107,18 @@ const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
throw new AppError(404, 'Account not linked');
|
||||
}
|
||||
|
||||
// Best-effort revoke tokens at the provider
|
||||
try {
|
||||
const accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv);
|
||||
if (account.serviceId === 'YOUTUBE') {
|
||||
await revokeYouTubeToken(accessToken);
|
||||
} else {
|
||||
await revokeTwitchToken(accessToken);
|
||||
// Best-effort revoke tokens at the provider (skip for CUSTOM_RTMP)
|
||||
if (account.serviceId !== 'CUSTOM_RTMP') {
|
||||
try {
|
||||
const accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv);
|
||||
if (account.serviceId === 'YOUTUBE') {
|
||||
await revokeYouTubeToken(accessToken);
|
||||
} else {
|
||||
await revokeTwitchToken(accessToken);
|
||||
}
|
||||
} catch {
|
||||
// Revocation failure is non-fatal
|
||||
}
|
||||
} catch {
|
||||
// Revocation failure is non-fatal
|
||||
}
|
||||
|
||||
await fastify.prisma.linkedAccount.delete({
|
||||
|
||||
@@ -11,8 +11,8 @@ import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js';
|
||||
|
||||
// In-memory CSRF state store (state → { userId, expiresAt })
|
||||
const pendingStates = new Map<string, { userId: string; expiresAt: number }>();
|
||||
// In-memory CSRF state store (state → { userId, expiresAt, source })
|
||||
const pendingStates = new Map<string, { userId: string; expiresAt: number; source?: string }>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -23,13 +23,15 @@ setInterval(() => {
|
||||
|
||||
const twitchRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /providers/twitch/auth-url — get OAuth URL with CSRF state
|
||||
fastify.get('/providers/twitch/auth-url', {
|
||||
fastify.get<{ Querystring: { source?: string } }>('/providers/twitch/auth-url', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const source = request.query.source;
|
||||
const state = randomUUID();
|
||||
pendingStates.set(state, {
|
||||
userId: request.userId,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
source,
|
||||
});
|
||||
|
||||
const response: AuthUrlResponse = {
|
||||
@@ -39,21 +41,27 @@ const twitchRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return response;
|
||||
});
|
||||
|
||||
// GET /providers/twitch/callback-redirect — Twitch redirects here → 302 to app deep link
|
||||
// GET /providers/twitch/callback-redirect — Twitch redirects here → 302 to app deep link or portal
|
||||
fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/providers/twitch/callback-redirect',
|
||||
async (request, reply) => {
|
||||
const { code, state, error } = request.query;
|
||||
|
||||
const pending = state ? pendingStates.get(state) : undefined;
|
||||
const isWeb = pending?.source === 'web';
|
||||
|
||||
if (error || !code || !state) {
|
||||
reply.status(302).redirect(
|
||||
`${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`,
|
||||
);
|
||||
const target = isWeb
|
||||
? `${config.portalUrl}/accounts/callback/twitch?error=${error || 'missing_params'}`
|
||||
: `${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`;
|
||||
reply.status(302).redirect(target);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(302).redirect(
|
||||
`${config.appScheme}://twitch/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
||||
);
|
||||
const target = isWeb
|
||||
? `${config.portalUrl}/accounts/callback/twitch?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
: `${config.appScheme}://twitch/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
|
||||
reply.status(302).redirect(target);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js';
|
||||
|
||||
// In-memory CSRF state store (state → { userId, expiresAt })
|
||||
const pendingStates = new Map<string, { userId: string; expiresAt: number }>();
|
||||
// In-memory CSRF state store (state → { userId, expiresAt, source })
|
||||
const pendingStates = new Map<string, { userId: string; expiresAt: number; source?: string }>();
|
||||
|
||||
// Clean expired states every 5 minutes
|
||||
setInterval(() => {
|
||||
@@ -24,13 +24,15 @@ setInterval(() => {
|
||||
|
||||
const youtubeRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /providers/youtube/auth-url — get OAuth URL with CSRF state
|
||||
fastify.get('/providers/youtube/auth-url', {
|
||||
fastify.get<{ Querystring: { source?: string } }>('/providers/youtube/auth-url', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const source = request.query.source;
|
||||
const state = randomUUID();
|
||||
pendingStates.set(state, {
|
||||
userId: request.userId,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 min
|
||||
source,
|
||||
});
|
||||
|
||||
const response: AuthUrlResponse = {
|
||||
@@ -40,21 +42,28 @@ const youtubeRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return response;
|
||||
});
|
||||
|
||||
// GET /providers/youtube/callback-redirect — Google redirects here → 302 to app deep link
|
||||
// GET /providers/youtube/callback-redirect — Google redirects here → 302 to app deep link or portal
|
||||
fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/providers/youtube/callback-redirect',
|
||||
async (request, reply) => {
|
||||
const { code, state, error } = request.query;
|
||||
|
||||
// Check source from pending state to decide redirect target
|
||||
const pending = state ? pendingStates.get(state) : undefined;
|
||||
const isWeb = pending?.source === 'web';
|
||||
|
||||
if (error || !code || !state) {
|
||||
reply.status(302).redirect(
|
||||
`${config.appScheme}://youtube/callback?error=${error || 'missing_params'}`,
|
||||
);
|
||||
const target = isWeb
|
||||
? `${config.portalUrl}/accounts/callback/youtube?error=${error || 'missing_params'}`
|
||||
: `${config.appScheme}://youtube/callback?error=${error || 'missing_params'}`;
|
||||
reply.status(302).redirect(target);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(302).redirect(
|
||||
`${config.appScheme}://youtube/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
||||
);
|
||||
const target = isWeb
|
||||
? `${config.portalUrl}/accounts/callback/youtube?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
: `${config.appScheme}://youtube/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
|
||||
reply.status(302).redirect(target);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
33
src/routes/signaling/websocket.ts
Normal file
33
src/routes/signaling/websocket.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { verifyAccessToken } from '../../plugins/auth.js';
|
||||
import { SignalingManager } from '../../services/signaling-manager.service.js';
|
||||
|
||||
export function createSignalingRoutes(signalingManager: SignalingManager): FastifyPluginAsync {
|
||||
const signalingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /signaling/ws?token=<accessToken>
|
||||
fastify.get('/signaling/ws', { websocket: true }, async (socket, request) => {
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Missing token' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await verifyAccessToken(token);
|
||||
} catch {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Invalid or expired token' }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
request.log.info({ userId }, 'Signaling WebSocket connected');
|
||||
await signalingManager.handleConnection(userId, socket);
|
||||
});
|
||||
};
|
||||
|
||||
return signalingRoutes;
|
||||
}
|
||||
159
src/routes/social/feed.ts
Normal file
159
src/routes/social/feed.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { optionalAuth } from '../../middleware/require-auth.js';
|
||||
import { PREVIEWS_DIR } from '../streams/preview.js';
|
||||
import { hasMediaAssets, THUMBNAILS_DIR } from '../../services/media-processing.service.js';
|
||||
import { autoDetectEndedPlans } from '../../services/stream-status.service.js';
|
||||
import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
|
||||
|
||||
const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /social/feed?filter=trending|following|recent&cursor=&limit=20
|
||||
fastify.get<{ Querystring: { filter?: string; cursor?: string; limit?: string } }>('/social/feed', {
|
||||
preHandler: [optionalAuth],
|
||||
}, async (request) => {
|
||||
const filter = request.query.filter || 'trending';
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursorId = request.query.cursor;
|
||||
|
||||
// Base condition: LIVE + ENDED plans that are public OR owned by the current user
|
||||
const baseWhere: any = {
|
||||
status: { in: ['LIVE', 'ENDED'] },
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
...(request.userId ? [{ userId: request.userId }] : []),
|
||||
],
|
||||
};
|
||||
|
||||
// If following filter, restrict to followed users (requires auth)
|
||||
if (filter === 'following' && request.userId) {
|
||||
const myFollowing = await fastify.prisma.follow.findMany({
|
||||
where: { followerId: request.userId },
|
||||
select: { followingId: true },
|
||||
});
|
||||
const followingIds = myFollowing.map(f => f.followingId);
|
||||
baseWhere.userId = { in: followingIds };
|
||||
} else if (filter === 'following' && !request.userId) {
|
||||
// Not logged in — return empty following feed
|
||||
return { items: [], nextCursor: null } as FeedResponse;
|
||||
} else if (filter === 'mine') {
|
||||
if (!request.userId) {
|
||||
return { items: [], nextCursor: null } as FeedResponse;
|
||||
}
|
||||
baseWhere.userId = request.userId;
|
||||
}
|
||||
|
||||
let orderBy: any;
|
||||
if (filter === 'trending') {
|
||||
orderBy = { likes: { _count: 'desc' as const } };
|
||||
} else {
|
||||
orderBy = { updatedAt: 'desc' as const };
|
||||
}
|
||||
|
||||
const plans = await fastify.prisma.streamPlan.findMany({
|
||||
where: baseWhere,
|
||||
include: {
|
||||
user: { select: { id: true, displayName: true, avatarUrl: true } },
|
||||
destinations: true,
|
||||
_count: { select: { likes: true } },
|
||||
},
|
||||
orderBy,
|
||||
take: limit + 1,
|
||||
...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
// Auto-detect streams that ended on the service side
|
||||
const livePlans = plans.filter(p => p.status === 'LIVE' || p.status === 'READY');
|
||||
if (livePlans.length > 0) {
|
||||
await autoDetectEndedPlans(fastify.prisma, livePlans);
|
||||
}
|
||||
|
||||
const hasMore = plans.length > limit;
|
||||
const items = plans.slice(0, limit);
|
||||
|
||||
// Check which plans the current user has liked (only if authenticated)
|
||||
const planIds = items.map(p => p.id);
|
||||
let likedSet = new Set<string>();
|
||||
if (request.userId) {
|
||||
const myLikes = await fastify.prisma.like.findMany({
|
||||
where: { userId: request.userId, planId: { in: planIds } },
|
||||
select: { planId: true },
|
||||
});
|
||||
likedSet = new Set(myLikes.map(l => l.planId));
|
||||
}
|
||||
|
||||
// Resolve Twitch channel names from linked accounts
|
||||
const twitchAccountIds = new Set<string>();
|
||||
for (const plan of items) {
|
||||
for (const d of plan.destinations) {
|
||||
if (d.serviceId === 'TWITCH' && d.linkedAccountId) {
|
||||
twitchAccountIds.add(d.linkedAccountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const twitchAccounts = twitchAccountIds.size > 0
|
||||
? await fastify.prisma.linkedAccount.findMany({
|
||||
where: { id: { in: [...twitchAccountIds] } },
|
||||
select: { id: true, displayName: true },
|
||||
})
|
||||
: [];
|
||||
const twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName]));
|
||||
|
||||
const feedItems: FeedItemResponse[] = items.map(plan => {
|
||||
const hasPreview = existsSync(join(PREVIEWS_DIR, `${plan.id}.mp4`));
|
||||
const assets = hasMediaAssets(plan.id);
|
||||
return {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
status: plan.status,
|
||||
executionMode: plan.executionMode,
|
||||
gameId: plan.gameId,
|
||||
isPublic: plan.isPublic,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
updatedAt: plan.updatedAt.toISOString(),
|
||||
destinations: plan.destinations.map(d => ({
|
||||
id: d.id,
|
||||
serviceId: d.serviceId,
|
||||
linkedAccountId: d.linkedAccountId,
|
||||
title: d.title,
|
||||
description: d.description,
|
||||
privacyStatus: d.privacyStatus,
|
||||
gameId: d.gameId,
|
||||
tags: d.tags,
|
||||
rtmpUrl: '',
|
||||
streamKey: '',
|
||||
// Twitch embed needs channel name, not numeric account ID
|
||||
broadcastId: d.serviceId === 'TWITCH'
|
||||
? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId)
|
||||
: d.broadcastId,
|
||||
status: d.status,
|
||||
})),
|
||||
user: plan.user,
|
||||
},
|
||||
previewUrl: hasPreview ? `/streams/plans/${plan.id}/preview` : null,
|
||||
posterUrl: assets.poster ? `/media/thumbnails/${plan.id}_poster.jpg` : null,
|
||||
thumbnailUrl: assets.thumb ? `/media/thumbnails/${plan.id}_thumb.jpg` : null,
|
||||
clipUrl: assets.clip ? `/media/thumbnails/${plan.id}_clip.mp4` : null,
|
||||
likeCount: plan._count.likes,
|
||||
commentCount: 0, // portal comments are ephemeral via WebSocket
|
||||
isLiked: likedSet.has(plan.id),
|
||||
};
|
||||
});
|
||||
|
||||
// LIVE streams first, then ENDED
|
||||
feedItems.sort((a, b) => {
|
||||
const aLive = a.plan.status === 'LIVE' ? 0 : 1;
|
||||
const bLive = b.plan.status === 'LIVE' ? 0 : 1;
|
||||
return aLive - bLive;
|
||||
});
|
||||
|
||||
const response: FeedResponse = {
|
||||
items: feedItems,
|
||||
nextCursor: hasMore ? items[items.length - 1].id : null,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
export default feedRoutes;
|
||||
198
src/routes/social/following.ts
Normal file
198
src/routes/social/following.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth, optionalAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import { formatPlan } from '../streams/plans.js';
|
||||
import type { PublicUserResponse, FollowListResponse } from '../../types/api.js';
|
||||
|
||||
const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /social/follow/:userId — follow a user
|
||||
fastify.post<{ Params: { userId: string } }>('/social/follow/:userId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const { userId: targetId } = request.params;
|
||||
|
||||
if (targetId === request.userId) {
|
||||
throw new AppError(400, 'Cannot follow yourself');
|
||||
}
|
||||
|
||||
const target = await fastify.prisma.user.findUnique({ where: { id: targetId } });
|
||||
if (!target) throw new AppError(404, 'User not found');
|
||||
|
||||
await fastify.prisma.follow.upsert({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: request.userId,
|
||||
followingId: targetId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
followerId: request.userId,
|
||||
followingId: targetId,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /social/follow/:userId — unfollow a user
|
||||
fastify.delete<{ Params: { userId: string } }>('/social/follow/:userId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const { userId: targetId } = request.params;
|
||||
|
||||
await fastify.prisma.follow.deleteMany({
|
||||
where: {
|
||||
followerId: request.userId,
|
||||
followingId: targetId,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// GET /social/followers — list my followers
|
||||
fastify.get<{ Querystring: { cursor?: string; limit?: string } }>('/social/followers', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursor = request.query.cursor;
|
||||
|
||||
const follows = await fastify.prisma.follow.findMany({
|
||||
where: { followingId: request.userId },
|
||||
include: {
|
||||
follower: {
|
||||
include: {
|
||||
_count: { select: { followers: true, following: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const hasMore = follows.length > limit;
|
||||
const items = follows.slice(0, limit);
|
||||
|
||||
// Check which ones the current user follows back
|
||||
const followerIds = items.map(f => f.followerId);
|
||||
const myFollowing = await fastify.prisma.follow.findMany({
|
||||
where: { followerId: request.userId, followingId: { in: followerIds } },
|
||||
select: { followingId: true },
|
||||
});
|
||||
const followingSet = new Set(myFollowing.map(f => f.followingId));
|
||||
|
||||
const users: PublicUserResponse[] = items.map(f => ({
|
||||
id: f.follower.id,
|
||||
displayName: f.follower.displayName,
|
||||
avatarUrl: f.follower.avatarUrl,
|
||||
bio: f.follower.bio,
|
||||
followerCount: f.follower._count.followers,
|
||||
followingCount: f.follower._count.following,
|
||||
isFollowing: followingSet.has(f.followerId),
|
||||
}));
|
||||
|
||||
const response: FollowListResponse = {
|
||||
users,
|
||||
nextCursor: hasMore ? items[items.length - 1].id : null,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
// GET /social/following — list who I follow
|
||||
fastify.get<{ Querystring: { cursor?: string; limit?: string } }>('/social/following', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursor = request.query.cursor;
|
||||
|
||||
const follows = await fastify.prisma.follow.findMany({
|
||||
where: { followerId: request.userId },
|
||||
include: {
|
||||
following: {
|
||||
include: {
|
||||
_count: { select: { followers: true, following: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const hasMore = follows.length > limit;
|
||||
const items = follows.slice(0, limit);
|
||||
|
||||
const users: PublicUserResponse[] = items.map(f => ({
|
||||
id: f.following.id,
|
||||
displayName: f.following.displayName,
|
||||
avatarUrl: f.following.avatarUrl,
|
||||
bio: f.following.bio,
|
||||
followerCount: f.following._count.followers,
|
||||
followingCount: f.following._count.following,
|
||||
isFollowing: true,
|
||||
}));
|
||||
|
||||
const response: FollowListResponse = {
|
||||
users,
|
||||
nextCursor: hasMore ? items[items.length - 1].id : null,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
// GET /social/users/:userId — public profile
|
||||
fastify.get<{ Params: { userId: string } }>('/social/users/:userId', {
|
||||
preHandler: [optionalAuth],
|
||||
}, async (request) => {
|
||||
const { userId: targetId } = request.params;
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: targetId },
|
||||
include: {
|
||||
_count: { select: { followers: true, following: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
|
||||
let isFollowing = false;
|
||||
if (request.userId) {
|
||||
const follow = await fastify.prisma.follow.findUnique({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: request.userId,
|
||||
followingId: targetId,
|
||||
},
|
||||
},
|
||||
});
|
||||
isFollowing = !!follow;
|
||||
}
|
||||
|
||||
// Fetch this user's public LIVE + ENDED streams
|
||||
const publicPlans = await fastify.prisma.streamPlan.findMany({
|
||||
where: {
|
||||
userId: targetId,
|
||||
isPublic: true,
|
||||
status: { in: ['LIVE', 'ENDED'] },
|
||||
},
|
||||
include: { destinations: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const response: PublicUserResponse = {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
bio: user.bio,
|
||||
followerCount: user._count.followers,
|
||||
followingCount: user._count.following,
|
||||
isFollowing,
|
||||
streams: publicPlans.map(formatPlan),
|
||||
};
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
export default followingRoutes;
|
||||
75
src/routes/social/likes.ts
Normal file
75
src/routes/social/likes.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { LikeStatusResponse } from '../../types/api.js';
|
||||
|
||||
const likesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /social/likes/:planId — like a plan
|
||||
fastify.post<{ Params: { planId: string } }>('/social/likes/:planId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const { planId } = request.params;
|
||||
|
||||
const plan = await fastify.prisma.streamPlan.findUnique({ where: { id: planId } });
|
||||
if (!plan) throw new AppError(404, 'Plan not found');
|
||||
|
||||
await fastify.prisma.like.upsert({
|
||||
where: {
|
||||
userId_planId: {
|
||||
userId: request.userId,
|
||||
planId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId: request.userId,
|
||||
planId,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /social/likes/:planId — unlike a plan
|
||||
fastify.delete<{ Params: { planId: string } }>('/social/likes/:planId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const { planId } = request.params;
|
||||
|
||||
await fastify.prisma.like.deleteMany({
|
||||
where: {
|
||||
userId: request.userId,
|
||||
planId,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// GET /social/likes/:planId — like count + isLiked
|
||||
fastify.get<{ Params: { planId: string } }>('/social/likes/:planId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const { planId } = request.params;
|
||||
|
||||
const [count, myLike] = await Promise.all([
|
||||
fastify.prisma.like.count({ where: { planId } }),
|
||||
fastify.prisma.like.findUnique({
|
||||
where: {
|
||||
userId_planId: {
|
||||
userId: request.userId,
|
||||
planId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const response: LikeStatusResponse = {
|
||||
count,
|
||||
isLiked: !!myLike,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
export default likesRoutes;
|
||||
@@ -82,11 +82,17 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
},
|
||||
}, async (request) => {
|
||||
request.log.info({ planId: request.params.id, userId: request.userId }, 'Prepare plan request');
|
||||
const plan = await fastify.prisma.streamPlan.findFirst({
|
||||
where: { id: request.params.id, userId: request.userId },
|
||||
include: { destinations: true },
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
if (!plan) {
|
||||
// Debug: check if plan exists under any user
|
||||
const anyPlan = await fastify.prisma.streamPlan.findUnique({ where: { id: request.params.id } });
|
||||
request.log.warn({ planId: request.params.id, userId: request.userId, existsUnderOtherUser: !!anyPlan, otherUserId: anyPlan?.userId }, 'Plan not found for user');
|
||||
throw new AppError(404, 'Stream plan not found');
|
||||
}
|
||||
|
||||
// If already READY, return the existing prepared data
|
||||
if (plan.status === 'READY') {
|
||||
@@ -94,7 +100,7 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
planId: plan.id,
|
||||
destinations: plan.destinations.map((dest) => ({
|
||||
id: dest.id,
|
||||
serviceId: dest.serviceId as 'YOUTUBE' | 'TWITCH',
|
||||
serviceId: dest.serviceId as 'YOUTUBE' | 'TWITCH' | 'CUSTOM',
|
||||
rtmpUrl: dest.rtmpUrl || '',
|
||||
streamKey: dest.streamKey || '',
|
||||
broadcastId: dest.broadcastId || '',
|
||||
@@ -108,73 +114,102 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
}
|
||||
|
||||
const prepared: PreparedDestination[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const dest of plan.destinations) {
|
||||
const { account, accessToken } = await getDecryptedTokenByAccountId(
|
||||
fastify.prisma,
|
||||
request.userId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
// CUSTOM destinations are already READY with rtmpUrl/streamKey set at creation
|
||||
if (dest.serviceId === 'CUSTOM') {
|
||||
if (dest.status !== 'READY') {
|
||||
await fastify.prisma.streamDestination.update({
|
||||
where: { id: dest.id },
|
||||
data: { status: 'READY' },
|
||||
});
|
||||
}
|
||||
prepared.push({
|
||||
id: dest.id,
|
||||
serviceId: 'CUSTOM',
|
||||
rtmpUrl: dest.rtmpUrl || '',
|
||||
streamKey: dest.streamKey || '',
|
||||
broadcastId: '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dest.serviceId === 'YOUTUBE') {
|
||||
const broadcast = await createYouTubeBroadcast(
|
||||
accessToken,
|
||||
dest.title,
|
||||
dest.description,
|
||||
dest.privacyStatus,
|
||||
try {
|
||||
const { account, accessToken } = await getDecryptedTokenByAccountId(
|
||||
fastify.prisma,
|
||||
request.userId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
|
||||
await fastify.prisma.streamDestination.update({
|
||||
where: { id: dest.id },
|
||||
data: {
|
||||
if (dest.serviceId === 'YOUTUBE') {
|
||||
const broadcast = await createYouTubeBroadcast(
|
||||
accessToken,
|
||||
dest.title,
|
||||
dest.description,
|
||||
dest.privacyStatus,
|
||||
);
|
||||
|
||||
await fastify.prisma.streamDestination.update({
|
||||
where: { id: dest.id },
|
||||
data: {
|
||||
rtmpUrl: broadcast.rtmpUrl,
|
||||
streamKey: broadcast.streamKey,
|
||||
broadcastId: broadcast.id,
|
||||
status: 'READY',
|
||||
},
|
||||
});
|
||||
|
||||
prepared.push({
|
||||
id: dest.id,
|
||||
serviceId: 'YOUTUBE',
|
||||
rtmpUrl: broadcast.rtmpUrl,
|
||||
streamKey: broadcast.streamKey,
|
||||
broadcastId: broadcast.id,
|
||||
status: 'READY',
|
||||
},
|
||||
});
|
||||
});
|
||||
} else if (dest.serviceId === 'TWITCH') {
|
||||
// Update channel info
|
||||
const tags = dest.tags ? dest.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||
await updateTwitchChannel(
|
||||
accessToken,
|
||||
account.accountId,
|
||||
dest.title,
|
||||
dest.gameId,
|
||||
tags,
|
||||
);
|
||||
|
||||
prepared.push({
|
||||
id: dest.id,
|
||||
serviceId: 'YOUTUBE',
|
||||
rtmpUrl: broadcast.rtmpUrl,
|
||||
streamKey: broadcast.streamKey,
|
||||
broadcastId: broadcast.id,
|
||||
});
|
||||
} else if (dest.serviceId === 'TWITCH') {
|
||||
// Update channel info
|
||||
const tags = dest.tags ? dest.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||
await updateTwitchChannel(
|
||||
accessToken,
|
||||
account.accountId,
|
||||
dest.title,
|
||||
dest.gameId,
|
||||
tags,
|
||||
);
|
||||
// Get stream key
|
||||
const streamKey = await getTwitchStreamKey(accessToken, account.accountId);
|
||||
|
||||
// Get stream key
|
||||
const streamKey = await getTwitchStreamKey(accessToken, account.accountId);
|
||||
await fastify.prisma.streamDestination.update({
|
||||
where: { id: dest.id },
|
||||
data: {
|
||||
rtmpUrl: TWITCH_RTMP_URL,
|
||||
streamKey,
|
||||
broadcastId: account.accountId,
|
||||
status: 'READY',
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.prisma.streamDestination.update({
|
||||
where: { id: dest.id },
|
||||
data: {
|
||||
prepared.push({
|
||||
id: dest.id,
|
||||
serviceId: 'TWITCH',
|
||||
rtmpUrl: TWITCH_RTMP_URL,
|
||||
streamKey,
|
||||
broadcastId: account.accountId,
|
||||
status: 'READY',
|
||||
},
|
||||
});
|
||||
|
||||
prepared.push({
|
||||
id: dest.id,
|
||||
serviceId: 'TWITCH',
|
||||
rtmpUrl: TWITCH_RTMP_URL,
|
||||
streamKey,
|
||||
broadcastId: account.accountId,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
request.log.error({ err, destId: dest.id, service: dest.serviceId }, `Failed to prepare ${dest.serviceId} destination`);
|
||||
errors.push(`${dest.serviceId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// At least one destination must be ready
|
||||
if (prepared.length === 0) {
|
||||
throw new AppError(500, `All destinations failed to prepare: ${errors.join('; ')}`);
|
||||
}
|
||||
|
||||
await fastify.prisma.streamPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: { status: 'READY' },
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { existsSync, unlinkSync, copyFileSync, renameSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js';
|
||||
import { autoDetectEndedPlans } from '../../services/stream-status.service.js';
|
||||
import { decrypt, encrypt } from '../../services/crypto.service.js';
|
||||
import type { CreateStreamPlanBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js';
|
||||
import { PREVIEWS_DIR } from './preview.js';
|
||||
import { getVideoDuration, THUMBNAILS_DIR } from '../../services/media-processing.service.js';
|
||||
import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js';
|
||||
|
||||
function formatPlan(plan: any): StreamPlanResponse {
|
||||
export function formatPlan(plan: any): StreamPlanResponse {
|
||||
return {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
status: plan.status,
|
||||
executionMode: plan.executionMode ?? 'IN_GAME',
|
||||
gameId: plan.gameId ?? '',
|
||||
isPublic: plan.isPublic ?? false,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
updatedAt: plan.updatedAt.toISOString(),
|
||||
destinations: (plan.destinations ?? []).map((d: any): StreamDestinationResponse => ({
|
||||
@@ -33,64 +38,6 @@ function formatPlan(plan: any): StreamPlanResponse {
|
||||
|
||||
const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
/** Check YouTube broadcast status and auto-end plans that are over */
|
||||
async function autoDetectEndedPlans(plans: any[], userId: string) {
|
||||
// Cache decrypted tokens by linkedAccountId to avoid redundant decrypts
|
||||
const tokenCache = new Map<string, string>();
|
||||
|
||||
for (const plan of plans) {
|
||||
if (plan.status !== 'LIVE' && plan.status !== 'READY') continue;
|
||||
const ytDest = plan.destinations.find(
|
||||
(d: any) => d.serviceId === 'YOUTUBE' && d.broadcastId,
|
||||
);
|
||||
if (!ytDest) continue;
|
||||
|
||||
try {
|
||||
let ytAccessToken = tokenCache.get(ytDest.linkedAccountId);
|
||||
if (!ytAccessToken) {
|
||||
const account = await fastify.prisma.linkedAccount.findFirst({
|
||||
where: { id: ytDest.linkedAccountId, userId },
|
||||
});
|
||||
if (!account) continue;
|
||||
|
||||
ytAccessToken = decrypt(account.accessTokenEnc, account.accessTokenIv);
|
||||
if (account.tokenExpiresAt < new Date(Date.now() + 60_000)) {
|
||||
const refreshToken = decrypt(account.refreshTokenEnc, account.refreshTokenIv);
|
||||
const result = await refreshYouTubeToken(refreshToken);
|
||||
ytAccessToken = result.accessToken;
|
||||
const accessEnc = encrypt(ytAccessToken);
|
||||
await fastify.prisma.linkedAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
accessTokenEnc: accessEnc.ciphertext,
|
||||
accessTokenIv: accessEnc.iv,
|
||||
tokenExpiresAt: new Date(Date.now() + result.expiresIn * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
tokenCache.set(ytDest.linkedAccountId, ytAccessToken);
|
||||
}
|
||||
|
||||
const ytStatus = await getYouTubeBroadcastStatus(ytAccessToken, ytDest.broadcastId!);
|
||||
if (ytStatus === 'complete' || ytStatus === 'revoked') {
|
||||
for (const dest of plan.destinations) {
|
||||
await fastify.prisma.streamDestination.update({
|
||||
where: { id: dest.id },
|
||||
data: { status: 'ENDED' },
|
||||
});
|
||||
}
|
||||
await fastify.prisma.streamPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: { status: 'ENDED' },
|
||||
});
|
||||
plan.status = 'ENDED';
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /streams/plans — list plans
|
||||
fastify.get('/streams/plans', {
|
||||
preHandler: [requireAuth],
|
||||
@@ -101,7 +48,11 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
await autoDetectEndedPlans(plans, request.userId);
|
||||
// Debug: log total plans in DB vs plans for this user
|
||||
const totalPlans = await fastify.prisma.streamPlan.count();
|
||||
request.log.info({ userId: request.userId, userPlans: plans.length, totalPlans }, 'List plans');
|
||||
|
||||
await autoDetectEndedPlans(fastify.prisma, plans);
|
||||
|
||||
return plans.map(formatPlan);
|
||||
});
|
||||
@@ -117,19 +68,22 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
||||
gameId: { type: 'string', maxLength: 200 },
|
||||
isPublic: { type: 'boolean' },
|
||||
destinations: {
|
||||
type: 'array',
|
||||
maxItems: 10,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['linkedAccountId', 'title'],
|
||||
required: ['title'],
|
||||
properties: {
|
||||
linkedAccountId: { type: 'string', minLength: 1 },
|
||||
linkedAccountId: { type: 'string' },
|
||||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
description: { type: 'string', maxLength: 5000 },
|
||||
privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] },
|
||||
gameId: { type: 'string', maxLength: 100 },
|
||||
tags: { type: 'string', maxLength: 500 },
|
||||
rtmpUrl: { type: 'string', maxLength: 500 },
|
||||
streamKey: { type: 'string', maxLength: 500 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -139,7 +93,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { name, executionMode, gameId, destinations } = request.body;
|
||||
const { name, executionMode, gameId, isPublic, destinations } = request.body;
|
||||
|
||||
// Verify user has the required linked accounts
|
||||
const linkedAccounts = await fastify.prisma.linkedAccount.findMany({
|
||||
@@ -148,7 +102,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a]));
|
||||
|
||||
// If no destinations provided, auto-create one per linked account
|
||||
const resolvedDestinations = destinations.length > 0
|
||||
const resolvedDestinations: CreateDestinationBody[] = destinations.length > 0
|
||||
? destinations
|
||||
: linkedAccounts.map((a) => ({
|
||||
linkedAccountId: a.id,
|
||||
@@ -164,9 +118,12 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
}
|
||||
|
||||
for (const dest of resolvedDestinations) {
|
||||
const account = linkedAccountMap.get(dest.linkedAccountId);
|
||||
if (!account) {
|
||||
throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`);
|
||||
const isCustom = dest.rtmpUrl && dest.streamKey;
|
||||
if (!isCustom) {
|
||||
const account = linkedAccountMap.get(dest.linkedAccountId ?? '');
|
||||
if (!account) {
|
||||
throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,12 +133,43 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
name,
|
||||
executionMode: executionMode ?? 'IN_GAME',
|
||||
gameId: gameId ?? '',
|
||||
isPublic: isPublic ?? true,
|
||||
destinations: {
|
||||
create: resolvedDestinations.map((d) => {
|
||||
const account = linkedAccountMap.get(d.linkedAccountId)!;
|
||||
const isCustom = d.rtmpUrl && d.streamKey;
|
||||
if (isCustom) {
|
||||
return {
|
||||
serviceId: 'CUSTOM',
|
||||
linkedAccountId: '',
|
||||
title: d.title,
|
||||
description: d.description ?? '',
|
||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||
gameId: d.gameId ?? '',
|
||||
tags: d.tags ?? '',
|
||||
rtmpUrl: d.rtmpUrl!,
|
||||
streamKey: d.streamKey!,
|
||||
status: 'READY',
|
||||
};
|
||||
}
|
||||
const account = linkedAccountMap.get(d.linkedAccountId ?? '')!;
|
||||
// CUSTOM_RTMP: decrypt stored credentials into destination
|
||||
if (account.serviceId === 'CUSTOM_RTMP') {
|
||||
return {
|
||||
serviceId: 'CUSTOM',
|
||||
linkedAccountId: d.linkedAccountId ?? '',
|
||||
title: d.title,
|
||||
description: d.description ?? '',
|
||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||
gameId: d.gameId ?? '',
|
||||
tags: d.tags ?? '',
|
||||
rtmpUrl: decrypt(account.accessTokenEnc, account.accessTokenIv),
|
||||
streamKey: decrypt(account.refreshTokenEnc, account.refreshTokenIv),
|
||||
status: 'READY',
|
||||
};
|
||||
}
|
||||
return {
|
||||
serviceId: account.serviceId,
|
||||
linkedAccountId: d.linkedAccountId,
|
||||
linkedAccountId: d.linkedAccountId ?? '',
|
||||
title: d.title,
|
||||
description: d.description ?? '',
|
||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||
@@ -194,6 +182,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
include: { destinations: true },
|
||||
});
|
||||
|
||||
request.log.info({ planId: plan.id, userId: request.userId, name, executionMode: executionMode ?? 'IN_GAME' }, 'Plan created');
|
||||
reply.status(201).send(formatPlan(plan));
|
||||
});
|
||||
|
||||
@@ -214,7 +203,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
|
||||
await autoDetectEndedPlans([plan], request.userId);
|
||||
await autoDetectEndedPlans(fastify.prisma, [plan]);
|
||||
|
||||
return formatPlan(plan);
|
||||
});
|
||||
@@ -235,7 +224,85 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
|
||||
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
|
||||
const previewPath = join(PREVIEWS_DIR, `${plan.id}.mp4`);
|
||||
const hasPreview = existsSync(previewPath);
|
||||
|
||||
// If plan is ENDED with a preview, preserve the stream as a Video
|
||||
if (plan.status === 'ENDED' && hasPreview) {
|
||||
request.log.info({ planId: plan.id, userId: request.userId }, 'Preserving ENDED plan as Video');
|
||||
|
||||
let duration = 0;
|
||||
try { duration = await getVideoDuration(previewPath); } catch { /* fallback 0 */ }
|
||||
const fileSize = statSync(previewPath).size;
|
||||
|
||||
// Create Video record
|
||||
const video = await fastify.prisma.video.create({
|
||||
data: {
|
||||
userId: plan.userId,
|
||||
title: plan.name,
|
||||
videoUrl: '', // updated after file move
|
||||
duration,
|
||||
fileSize,
|
||||
isPublic: plan.isPublic,
|
||||
sourcePlanId: plan.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Move preview file to videos directory
|
||||
const videosDir = join(process.cwd(), 'data', 'videos');
|
||||
const videoPath = join(videosDir, `${video.id}.mp4`);
|
||||
try {
|
||||
renameSync(previewPath, videoPath);
|
||||
} catch {
|
||||
// Cross-device: copy + delete
|
||||
copyFileSync(previewPath, videoPath);
|
||||
try { unlinkSync(previewPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Copy thumbnail assets
|
||||
const suffixes = ['_poster.jpg', '_thumb.jpg', '_clip.mp4'];
|
||||
for (const suffix of suffixes) {
|
||||
const src = join(THUMBNAILS_DIR, `${plan.id}${suffix}`);
|
||||
const dst = join(THUMBNAILS_DIR, `${video.id}${suffix}`);
|
||||
if (existsSync(src)) {
|
||||
try { copyFileSync(src, dst); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Update video with URL
|
||||
await fastify.prisma.video.update({
|
||||
where: { id: video.id },
|
||||
data: { videoUrl: `/content/videos/${video.id}/stream` },
|
||||
});
|
||||
|
||||
// Delete the plan from DB (cascades destinations/likes)
|
||||
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
|
||||
|
||||
// Clean up old plan thumbnail assets
|
||||
for (const suffix of suffixes) {
|
||||
const src = join(THUMBNAILS_DIR, `${plan.id}${suffix}`);
|
||||
if (existsSync(src)) {
|
||||
try { unlinkSync(src); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DRAFT or no preview: delete plan and clean up files
|
||||
request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan');
|
||||
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
|
||||
|
||||
if (hasPreview) {
|
||||
try { unlinkSync(previewPath); } catch { /* ignore */ }
|
||||
}
|
||||
// Clean up thumbnail assets
|
||||
const suffixes = ['_poster.jpg', '_thumb.jpg', '_clip.mp4'];
|
||||
for (const suffix of suffixes) {
|
||||
const src = join(THUMBNAILS_DIR, `${plan.id}${suffix}`);
|
||||
if (existsSync(src)) {
|
||||
try { unlinkSync(src); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
@@ -254,19 +321,22 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
||||
gameId: { type: 'string', maxLength: 200 },
|
||||
isPublic: { type: 'boolean' },
|
||||
destinations: {
|
||||
type: 'array',
|
||||
maxItems: 10,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['linkedAccountId', 'title'],
|
||||
required: ['title'],
|
||||
properties: {
|
||||
linkedAccountId: { type: 'string', minLength: 1 },
|
||||
linkedAccountId: { type: 'string' },
|
||||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
description: { type: 'string', maxLength: 5000 },
|
||||
privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] },
|
||||
gameId: { type: 'string', maxLength: 100 },
|
||||
tags: { type: 'string', maxLength: 500 },
|
||||
rtmpUrl: { type: 'string', maxLength: 500 },
|
||||
streamKey: { type: 'string', maxLength: 500 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -283,7 +353,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
if (plan.status !== 'DRAFT') throw new AppError(400, 'Only DRAFT plans can be edited');
|
||||
|
||||
const { name, executionMode, gameId, destinations } = request.body;
|
||||
const { name, executionMode, gameId, isPublic, destinations } = request.body;
|
||||
|
||||
// If destinations provided, verify accounts and replace them
|
||||
if (destinations) {
|
||||
@@ -293,7 +363,8 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a]));
|
||||
|
||||
for (const dest of destinations) {
|
||||
if (!linkedAccountMap.has(dest.linkedAccountId)) {
|
||||
const isCustom = dest.rtmpUrl && dest.streamKey;
|
||||
if (!isCustom && !linkedAccountMap.has(dest.linkedAccountId ?? '')) {
|
||||
throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`);
|
||||
}
|
||||
}
|
||||
@@ -301,19 +372,57 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// Delete old destinations and create new ones
|
||||
await fastify.prisma.streamDestination.deleteMany({ where: { planId: plan.id } });
|
||||
for (const d of destinations) {
|
||||
const account = linkedAccountMap.get(d.linkedAccountId)!;
|
||||
await fastify.prisma.streamDestination.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
serviceId: account.serviceId,
|
||||
linkedAccountId: d.linkedAccountId,
|
||||
title: d.title,
|
||||
description: d.description ?? '',
|
||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||
gameId: d.gameId ?? '',
|
||||
tags: d.tags ?? '',
|
||||
},
|
||||
});
|
||||
const isCustom = d.rtmpUrl && d.streamKey;
|
||||
if (isCustom) {
|
||||
await fastify.prisma.streamDestination.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
serviceId: 'CUSTOM',
|
||||
linkedAccountId: '',
|
||||
title: d.title,
|
||||
description: d.description ?? '',
|
||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||
gameId: d.gameId ?? '',
|
||||
tags: d.tags ?? '',
|
||||
rtmpUrl: d.rtmpUrl!,
|
||||
streamKey: d.streamKey!,
|
||||
status: 'READY',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const account = linkedAccountMap.get(d.linkedAccountId ?? '')!;
|
||||
// CUSTOM_RTMP: decrypt stored credentials into destination
|
||||
if (account.serviceId === 'CUSTOM_RTMP') {
|
||||
await fastify.prisma.streamDestination.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
serviceId: 'CUSTOM',
|
||||
linkedAccountId: d.linkedAccountId ?? '',
|
||||
title: d.title,
|
||||
description: d.description ?? '',
|
||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||
gameId: d.gameId ?? '',
|
||||
tags: d.tags ?? '',
|
||||
rtmpUrl: decrypt(account.accessTokenEnc, account.accessTokenIv),
|
||||
streamKey: decrypt(account.refreshTokenEnc, account.refreshTokenIv),
|
||||
status: 'READY',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await fastify.prisma.streamDestination.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
serviceId: account.serviceId,
|
||||
linkedAccountId: d.linkedAccountId ?? '',
|
||||
title: d.title,
|
||||
description: d.description ?? '',
|
||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||
gameId: d.gameId ?? '',
|
||||
tags: d.tags ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,12 +433,44 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
...(name !== undefined && { name }),
|
||||
...(executionMode !== undefined && { executionMode }),
|
||||
...(gameId !== undefined && { gameId }),
|
||||
...(isPublic !== undefined && { isPublic }),
|
||||
},
|
||||
include: { destinations: true },
|
||||
});
|
||||
|
||||
reply.status(200).send(formatPlan(updated));
|
||||
});
|
||||
|
||||
// PATCH /streams/plans/:id/visibility — toggle visibility on any status
|
||||
fastify.patch<{ Params: { id: string }; Body: { isPublic: boolean } }>('/streams/plans/:id/visibility', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['isPublic'],
|
||||
properties: { isPublic: { type: 'boolean' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const plan = await fastify.prisma.streamPlan.findFirst({
|
||||
where: { id: request.params.id, userId: request.userId },
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
|
||||
const updated = await fastify.prisma.streamPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: { isPublic: request.body.isPublic },
|
||||
include: { destinations: true },
|
||||
});
|
||||
|
||||
reply.status(200).send(formatPlan(updated));
|
||||
});
|
||||
};
|
||||
|
||||
export default planRoutes;
|
||||
|
||||
101
src/routes/streams/preview.ts
Normal file
101
src/routes/streams/preview.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, createReadStream, createWriteStream, statSync, unlinkSync } from 'fs';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { generateMediaAssets } from '../../services/media-processing.service.js';
|
||||
|
||||
const PREVIEWS_DIR = join(process.cwd(), 'data', 'previews');
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// Ensure previews directory exists
|
||||
if (!existsSync(PREVIEWS_DIR)) {
|
||||
mkdirSync(PREVIEWS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const previewRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// POST /streams/plans/:id/preview — upload preview clip (multipart)
|
||||
fastify.post<{ Params: { id: string } }>('/streams/plans/:id/preview', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const plan = await fastify.prisma.streamPlan.findFirst({
|
||||
where: { id: request.params.id, userId: request.userId },
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
|
||||
const data = await request.file({ limits: { fileSize: MAX_FILE_SIZE } });
|
||||
if (!data) throw new AppError(400, 'No file uploaded');
|
||||
|
||||
const outputPath = join(PREVIEWS_DIR, `${plan.id}.mp4`);
|
||||
await pipeline(data.file, createWriteStream(outputPath));
|
||||
|
||||
// Check if file was truncated (exceeded size limit)
|
||||
if (data.file.truncated) {
|
||||
unlinkSync(outputPath);
|
||||
throw new AppError(413, 'File too large (max 10MB)');
|
||||
}
|
||||
|
||||
request.log.info({ planId: plan.id }, 'Preview clip uploaded');
|
||||
|
||||
// Generate thumbnails async (fire-and-forget)
|
||||
generateMediaAssets(outputPath, plan.id, request.log).catch(() => {});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// GET /streams/plans/:id/preview — serve preview clip with range support
|
||||
fastify.get<{ Params: { id: string } }>('/streams/plans/:id/preview', {
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const filePath = join(PREVIEWS_DIR, `${request.params.id}.mp4`);
|
||||
if (!existsSync(filePath)) {
|
||||
throw new AppError(404, 'Preview not found');
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = request.headers.range;
|
||||
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Cache-Control', 'public, max-age=30');
|
||||
reply.header('Content-Type', 'video/mp4');
|
||||
|
||||
if (range) {
|
||||
// Parse byte range
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
|
||||
if (start >= fileSize || end >= fileSize || start > end) {
|
||||
reply.status(416).header('Content-Range', `bytes */${fileSize}`).send();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(206);
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||
reply.header('Content-Length', end - start + 1);
|
||||
return reply.send(createReadStream(filePath, { start, end }));
|
||||
}
|
||||
|
||||
reply.header('Content-Length', fileSize);
|
||||
return reply.send(createReadStream(filePath));
|
||||
});
|
||||
};
|
||||
|
||||
export default previewRoutes;
|
||||
export { PREVIEWS_DIR };
|
||||
589
src/services/chat-manager.service.ts
Normal file
589
src/services/chat-manager.service.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { decrypt, encrypt } from './crypto.service.js';
|
||||
import { refreshYouTubeToken } from './youtube.service.js';
|
||||
import { refreshTwitchToken } from './twitch.service.js';
|
||||
import {
|
||||
getYouTubeLiveChatId,
|
||||
pollYouTubeChatMessages,
|
||||
sendYouTubeChatMessage,
|
||||
} from './youtube-chat.service.js';
|
||||
import { TwitchChatClient } from './twitch-chat.service.js';
|
||||
|
||||
interface ChatSession {
|
||||
planId: string;
|
||||
userId: string;
|
||||
socket: WebSocket;
|
||||
youtubePollers: Map<string, { timer: ReturnType<typeof setTimeout>; liveChatId: string; pageToken: string }>;
|
||||
twitchClients: Map<string, TwitchChatClient>;
|
||||
}
|
||||
|
||||
async function getDecryptedToken(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
linkedAccountId: string,
|
||||
): Promise<{ account: any; accessToken: string }> {
|
||||
const account = await (prisma as any).linkedAccount.findFirst({
|
||||
where: { id: linkedAccountId, userId },
|
||||
});
|
||||
if (!account) throw new Error(`Linked account ${linkedAccountId} not found`);
|
||||
|
||||
// Lazy refresh if token expires within 60s
|
||||
if (account.tokenExpiresAt < new Date(Date.now() + 60 * 1000)) {
|
||||
const refreshToken = decrypt(account.refreshTokenEnc, account.refreshTokenIv);
|
||||
let newAccess: string;
|
||||
let newRefresh: string | undefined;
|
||||
let expiresIn: number;
|
||||
|
||||
if (account.serviceId === 'YOUTUBE') {
|
||||
const result = await refreshYouTubeToken(refreshToken);
|
||||
newAccess = result.accessToken;
|
||||
expiresIn = result.expiresIn;
|
||||
} else {
|
||||
const result = await refreshTwitchToken(refreshToken);
|
||||
newAccess = result.accessToken;
|
||||
newRefresh = result.refreshToken;
|
||||
expiresIn = result.expiresIn;
|
||||
}
|
||||
|
||||
const accessEnc = encrypt(newAccess);
|
||||
const updateData: any = {
|
||||
accessTokenEnc: accessEnc.ciphertext,
|
||||
accessTokenIv: accessEnc.iv,
|
||||
tokenExpiresAt: new Date(Date.now() + expiresIn * 1000),
|
||||
};
|
||||
if (newRefresh) {
|
||||
const refreshEnc = encrypt(newRefresh);
|
||||
updateData.refreshTokenEnc = refreshEnc.ciphertext;
|
||||
updateData.refreshTokenIv = refreshEnc.iv;
|
||||
}
|
||||
|
||||
await (prisma as any).linkedAccount.update({
|
||||
where: { id: account.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return { account, accessToken: newAccess };
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
accessToken: decrypt(account.accessTokenEnc, account.accessTokenIv),
|
||||
};
|
||||
}
|
||||
|
||||
interface PortalSubscriber {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
socket: WebSocket;
|
||||
}
|
||||
|
||||
export class ChatManager {
|
||||
private sessions = new Map<string, ChatSession>(); // key: `${userId}:${planId}`
|
||||
private portalSubscribers = new Map<string, Set<PortalSubscriber>>(); // key: planId
|
||||
private prisma: PrismaClient;
|
||||
private logger: FastifyBaseLogger;
|
||||
|
||||
constructor(prisma: PrismaClient, logger: FastifyBaseLogger) {
|
||||
this.prisma = prisma;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async startChat(planId: string, userId: string, socket: WebSocket): Promise<void> {
|
||||
const sessionKey = `${userId}:${planId}`;
|
||||
this.logger.info({ planId, userId, sessionKey }, 'startChat called');
|
||||
|
||||
// Stop existing session for this plan
|
||||
await this.stopChat(planId, userId);
|
||||
|
||||
const plan = await (this.prisma as any).streamPlan.findFirst({
|
||||
where: { id: planId, userId },
|
||||
include: { destinations: true },
|
||||
});
|
||||
if (!plan) {
|
||||
this.logger.warn({ planId, userId }, 'startChat: plan not found');
|
||||
this.sendToSocket(socket, { type: 'chat_status', planId, error: 'Plan not found' });
|
||||
return;
|
||||
}
|
||||
this.logger.info({ planId, destCount: plan.destinations.length, destServices: plan.destinations.map((d: any) => d.serviceId) }, 'startChat: plan loaded');
|
||||
|
||||
const session: ChatSession = {
|
||||
planId,
|
||||
userId,
|
||||
socket,
|
||||
youtubePollers: new Map(),
|
||||
twitchClients: new Map(),
|
||||
};
|
||||
|
||||
this.sessions.set(sessionKey, session);
|
||||
|
||||
const chatPromises: Promise<void>[] = [];
|
||||
for (const dest of plan.destinations) {
|
||||
if (dest.serviceId === 'YOUTUBE' && dest.linkedAccountId) {
|
||||
chatPromises.push(this.startYouTubeChat(session, dest));
|
||||
} else if (dest.serviceId === 'TWITCH' && dest.linkedAccountId) {
|
||||
chatPromises.push(this.startTwitchChat(session, dest));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(chatPromises);
|
||||
}
|
||||
|
||||
async handleSendMessage(
|
||||
planId: string,
|
||||
userId: string,
|
||||
destinationId: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const sessionKey = `${userId}:${planId}`;
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return;
|
||||
|
||||
// Check if it's a YouTube destination
|
||||
const ytPoller = session.youtubePollers.get(destinationId);
|
||||
if (ytPoller) {
|
||||
try {
|
||||
const { accessToken } = await getDecryptedToken(this.prisma, userId, destinationId);
|
||||
await sendYouTubeChatMessage(accessToken, ytPoller.liveChatId, text);
|
||||
} catch (err) {
|
||||
this.logger.error({ err, destinationId }, 'Failed to send YouTube chat message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a Twitch destination
|
||||
const twitchClient = session.twitchClients.get(destinationId);
|
||||
if (twitchClient) {
|
||||
twitchClient.sendMessage(text);
|
||||
// Echo sent message back to app (Twitch IRC doesn't echo your own PRIVMSGs)
|
||||
try {
|
||||
const { account } = await getDecryptedToken(this.prisma, userId, destinationId);
|
||||
this.sendEcho(session, 'TWITCH', destinationId, account.displayName, text);
|
||||
} catch {
|
||||
// Still echo with fallback name
|
||||
this.sendEcho(session, 'TWITCH', destinationId, 'You', text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendEcho(
|
||||
session: ChatSession,
|
||||
service: string,
|
||||
destinationId: string,
|
||||
authorName: string,
|
||||
text: string,
|
||||
): void {
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_message',
|
||||
planId: session.planId,
|
||||
service,
|
||||
destinationId,
|
||||
message: {
|
||||
id: `echo-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
|
||||
authorName,
|
||||
authorImageUrl: null,
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
isModerator: false,
|
||||
isBroadcaster: true,
|
||||
color: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async subscribePortalChat(planId: string, userId: string, socket: WebSocket): Promise<void> {
|
||||
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
|
||||
if (!user) return;
|
||||
|
||||
if (!this.portalSubscribers.has(planId)) {
|
||||
this.portalSubscribers.set(planId, new Set());
|
||||
}
|
||||
|
||||
// Remove existing subscription for this user+plan
|
||||
const subs = this.portalSubscribers.get(planId)!;
|
||||
for (const sub of subs) {
|
||||
if (sub.userId === userId) {
|
||||
subs.delete(sub);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
subs.add({
|
||||
userId,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
socket,
|
||||
});
|
||||
|
||||
this.logger.info({ planId, userId }, 'Portal chat subscribed');
|
||||
}
|
||||
|
||||
async handlePortalComment(planId: string, userId: string, text: string): Promise<void> {
|
||||
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
|
||||
if (!user) return;
|
||||
|
||||
const message = {
|
||||
id: `portal-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
|
||||
authorName: user.displayName,
|
||||
authorImageUrl: user.avatarUrl,
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
isModerator: false,
|
||||
isBroadcaster: false,
|
||||
color: '#00BCD4',
|
||||
};
|
||||
|
||||
// Broadcast to all portal subscribers watching this plan
|
||||
const subs = this.portalSubscribers.get(planId);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
this.sendToSocket(sub.socket, {
|
||||
type: 'chat_message',
|
||||
planId,
|
||||
service: 'PORTAL',
|
||||
destinationId: 'portal',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also send to the plan owner's chat session (so it shows up in their Android app)
|
||||
const plan = await (this.prisma as any).streamPlan.findUnique({ where: { id: planId } });
|
||||
if (plan) {
|
||||
const ownerSession = this.sessions.get(`${plan.userId}:${planId}`);
|
||||
if (ownerSession) {
|
||||
this.sendToSocket(ownerSession.socket, {
|
||||
type: 'chat_message',
|
||||
planId,
|
||||
service: 'PORTAL',
|
||||
destinationId: 'portal',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleLike(planId: string, userId: string): Promise<void> {
|
||||
// Toggle like in DB
|
||||
const existing = await (this.prisma as any).like.findUnique({
|
||||
where: { userId_planId: { userId, planId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await (this.prisma as any).like.delete({ where: { id: existing.id } });
|
||||
} else {
|
||||
await (this.prisma as any).like.create({ data: { userId, planId } });
|
||||
}
|
||||
|
||||
const count = await (this.prisma as any).like.count({ where: { planId } });
|
||||
|
||||
// Broadcast like update to all portal subscribers
|
||||
const subs = this.portalSubscribers.get(planId);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
this.sendToSocket(sub.socket, {
|
||||
type: 'like_update',
|
||||
planId,
|
||||
count,
|
||||
isLiked: sub.userId === userId ? !existing : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stopChat(planId: string, userId: string): Promise<void> {
|
||||
const sessionKey = `${userId}:${planId}`;
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return;
|
||||
|
||||
// Stop YouTube pollers
|
||||
for (const [, poller] of session.youtubePollers) {
|
||||
clearTimeout(poller.timer);
|
||||
}
|
||||
session.youtubePollers.clear();
|
||||
|
||||
// Disconnect Twitch clients
|
||||
for (const [, client] of session.twitchClients) {
|
||||
client.disconnect();
|
||||
}
|
||||
session.twitchClients.clear();
|
||||
|
||||
this.sessions.delete(sessionKey);
|
||||
}
|
||||
|
||||
stopAllForSocket(socket: WebSocket): void {
|
||||
for (const [key, session] of this.sessions) {
|
||||
if (session.socket === socket) {
|
||||
// Stop YouTube pollers
|
||||
for (const [, poller] of session.youtubePollers) {
|
||||
clearTimeout(poller.timer);
|
||||
}
|
||||
session.youtubePollers.clear();
|
||||
|
||||
// Disconnect Twitch clients
|
||||
for (const [, client] of session.twitchClients) {
|
||||
client.disconnect();
|
||||
}
|
||||
session.twitchClients.clear();
|
||||
|
||||
this.sessions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up portal subscriptions for this socket
|
||||
for (const [planId, subs] of this.portalSubscribers) {
|
||||
for (const sub of subs) {
|
||||
if (sub.socket === socket) {
|
||||
subs.delete(sub);
|
||||
}
|
||||
}
|
||||
if (subs.size === 0) {
|
||||
this.portalSubscribers.delete(planId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async startYouTubeChat(session: ChatSession, dest: any): Promise<void> {
|
||||
try {
|
||||
const { accessToken } = await getDecryptedToken(
|
||||
this.prisma,
|
||||
session.userId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
|
||||
// Need broadcastId to get liveChatId
|
||||
if (!dest.broadcastId) {
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'YOUTUBE',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: false,
|
||||
error: 'No broadcast ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry getting liveChatId — YouTube may still be transitioning to live
|
||||
let liveChatId: string | null = null;
|
||||
const MAX_RETRIES = 12; // ~60s total (12 * 5s)
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
// Re-check session is still alive
|
||||
if (!this.sessions.has(`${session.userId}:${session.planId}`)) return;
|
||||
|
||||
const { accessToken: freshToken } = await getDecryptedToken(
|
||||
this.prisma,
|
||||
session.userId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
liveChatId = await getYouTubeLiveChatId(freshToken, dest.broadcastId);
|
||||
if (liveChatId) break;
|
||||
|
||||
this.logger.info(
|
||||
{ planId: session.planId, broadcastId: dest.broadcastId, attempt: attempt + 1 },
|
||||
'YouTube liveChatId not yet available, retrying...',
|
||||
);
|
||||
|
||||
if (attempt === 0) {
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'YOUTUBE',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: false,
|
||||
error: 'Waiting for broadcast to go live...',
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
if (!liveChatId) {
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'YOUTUBE',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: false,
|
||||
error: 'No active live chat after retries',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'YOUTUBE',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: true,
|
||||
});
|
||||
|
||||
// Start polling loop
|
||||
const pollerState = { timer: setTimeout(() => {}, 0), liveChatId, pageToken: '' };
|
||||
session.youtubePollers.set(dest.linkedAccountId, pollerState);
|
||||
|
||||
const poll = async () => {
|
||||
// Verify session is still alive
|
||||
if (!session.youtubePollers.has(dest.linkedAccountId)) {
|
||||
this.logger.info({ planId: session.planId }, 'YouTube poll skipped: poller removed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.info({ planId: session.planId, liveChatId }, 'YouTube poll executing');
|
||||
const { accessToken: token } = await getDecryptedToken(
|
||||
this.prisma,
|
||||
session.userId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
const result = await pollYouTubeChatMessages(
|
||||
token,
|
||||
liveChatId,
|
||||
pollerState.pageToken || undefined,
|
||||
);
|
||||
|
||||
this.logger.info({ planId: session.planId, messageCount: result.messages.length, nextInterval: result.pollingIntervalMillis }, 'YouTube poll result');
|
||||
pollerState.pageToken = result.nextPageToken;
|
||||
|
||||
for (const msg of result.messages) {
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_message',
|
||||
planId: session.planId,
|
||||
service: 'YOUTUBE',
|
||||
destinationId: dest.linkedAccountId,
|
||||
message: {
|
||||
id: msg.id,
|
||||
authorName: msg.authorName,
|
||||
authorImageUrl: msg.authorImageUrl,
|
||||
text: msg.text,
|
||||
timestamp: new Date(msg.publishedAt).getTime(),
|
||||
isModerator: msg.isModerator,
|
||||
isBroadcaster: msg.isChatOwner,
|
||||
color: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule next poll respecting API interval
|
||||
const interval = Math.max(result.pollingIntervalMillis, 5000);
|
||||
pollerState.timer = setTimeout(poll, interval);
|
||||
} catch (err) {
|
||||
this.logger.error({ err, planId: session.planId }, 'YouTube chat poll error');
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'YOUTUBE',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: false,
|
||||
error: 'Poll failed',
|
||||
});
|
||||
// Retry after 10s
|
||||
pollerState.timer = setTimeout(poll, 10_000);
|
||||
}
|
||||
};
|
||||
|
||||
// First poll immediately
|
||||
clearTimeout(pollerState.timer);
|
||||
pollerState.timer = setTimeout(poll, 0);
|
||||
} catch (err) {
|
||||
this.logger.error({ err, planId: session.planId }, 'Failed to start YouTube chat');
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'YOUTUBE',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: false,
|
||||
error: 'Failed to initialize',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async startTwitchChat(session: ChatSession, dest: any): Promise<void> {
|
||||
this.logger.info({ planId: session.planId, destId: dest.linkedAccountId }, 'startTwitchChat called');
|
||||
try {
|
||||
const { account, accessToken } = await getDecryptedToken(
|
||||
this.prisma,
|
||||
session.userId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
|
||||
const channel = account.displayName;
|
||||
const client = new TwitchChatClient(channel, accessToken, account.displayName);
|
||||
|
||||
client.on('connected', () => {
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'TWITCH',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: true,
|
||||
});
|
||||
});
|
||||
|
||||
client.on('message', (msg) => {
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_message',
|
||||
planId: session.planId,
|
||||
service: 'TWITCH',
|
||||
destinationId: dest.linkedAccountId,
|
||||
message: {
|
||||
id: msg.id,
|
||||
authorName: msg.authorName,
|
||||
authorImageUrl: null,
|
||||
text: msg.text,
|
||||
timestamp: msg.timestamp,
|
||||
isModerator: msg.isModerator,
|
||||
isBroadcaster: msg.isBroadcaster,
|
||||
color: msg.color || null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
client.on('disconnected', () => {
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'TWITCH',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: false,
|
||||
});
|
||||
});
|
||||
|
||||
client.on('error', (err: Error) => {
|
||||
this.logger.error({ err, planId: session.planId }, 'Twitch chat error');
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'TWITCH',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: false,
|
||||
error: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
session.twitchClients.set(dest.linkedAccountId, client);
|
||||
client.connect();
|
||||
} catch (err) {
|
||||
this.logger.error({ err, planId: session.planId }, 'Failed to start Twitch chat');
|
||||
this.sendToSocket(session.socket, {
|
||||
type: 'chat_status',
|
||||
planId: session.planId,
|
||||
service: 'TWITCH',
|
||||
destinationId: dest.linkedAccountId,
|
||||
connected: false,
|
||||
error: 'Failed to initialize',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sendToSocket(socket: WebSocket, data: Record<string, unknown>): void {
|
||||
try {
|
||||
if (socket.readyState === 1) { // WebSocket.OPEN
|
||||
socket.send(JSON.stringify(data));
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({ err }, 'Failed to send to WebSocket');
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/services/media-processing.service.ts
Normal file
112
src/services/media-processing.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import ffmpegStatic from 'ffmpeg-static';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
|
||||
const THUMBNAILS_DIR = join(process.cwd(), 'data', 'thumbnails');
|
||||
|
||||
// Ensure thumbnails directory exists
|
||||
if (!existsSync(THUMBNAILS_DIR)) {
|
||||
mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Set ffmpeg binary path
|
||||
if (ffmpegStatic) {
|
||||
ffmpeg.setFfmpegPath(ffmpegStatic as unknown as string);
|
||||
}
|
||||
|
||||
export { THUMBNAILS_DIR };
|
||||
|
||||
/**
|
||||
* Generate poster, thumbnail, and clip from a video file.
|
||||
* Runs async — caller should fire-and-forget.
|
||||
*/
|
||||
export async function generateMediaAssets(
|
||||
inputPath: string,
|
||||
outputId: string,
|
||||
log?: { info: (...args: any[]) => void; error: (...args: any[]) => void },
|
||||
): Promise<void> {
|
||||
const posterPath = join(THUMBNAILS_DIR, `${outputId}_poster.jpg`);
|
||||
const thumbPath = join(THUMBNAILS_DIR, `${outputId}_thumb.jpg`);
|
||||
const clipPath = join(THUMBNAILS_DIR, `${outputId}_clip.mp4`);
|
||||
|
||||
try {
|
||||
// 1. First-frame poster (720px wide, JPEG quality 80)
|
||||
await runFfmpeg(
|
||||
ffmpeg(inputPath)
|
||||
.screenshots({
|
||||
count: 1,
|
||||
timemarks: ['0.1'],
|
||||
filename: `${outputId}_poster.jpg`,
|
||||
folder: THUMBNAILS_DIR,
|
||||
size: '720x?',
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. Square thumbnail (300x300, center-crop)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ffmpeg(inputPath)
|
||||
.seekInput(0.1)
|
||||
.frames(1)
|
||||
.videoFilter('crop=min(iw\\,ih):min(iw\\,ih),scale=300:300')
|
||||
.outputOptions(['-q:v', '5'])
|
||||
.output(thumbPath)
|
||||
.on('end', () => resolve())
|
||||
.on('error', (err) => reject(err))
|
||||
.run();
|
||||
});
|
||||
|
||||
// 3. Short clip (first 3 seconds, 480px wide, 500kbps)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ffmpeg(inputPath)
|
||||
.duration(3)
|
||||
.videoFilter('scale=480:-2')
|
||||
.videoBitrate('500k')
|
||||
.outputOptions(['-movflags', '+faststart'])
|
||||
.output(clipPath)
|
||||
.on('end', () => resolve())
|
||||
.on('error', (err) => reject(err))
|
||||
.run();
|
||||
});
|
||||
|
||||
log?.info({ outputId }, 'Media assets generated');
|
||||
} catch (err) {
|
||||
log?.error({ outputId, err }, 'Media asset generation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video duration in seconds via ffprobe.
|
||||
*/
|
||||
export function getVideoDuration(filePath: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
||||
if (err) return reject(err);
|
||||
resolve(Math.round(metadata.format.duration ?? 0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if thumbnail assets exist for a given ID.
|
||||
*/
|
||||
export function hasMediaAssets(id: string): {
|
||||
poster: boolean;
|
||||
thumb: boolean;
|
||||
clip: boolean;
|
||||
} {
|
||||
return {
|
||||
poster: existsSync(join(THUMBNAILS_DIR, `${id}_poster.jpg`)),
|
||||
thumb: existsSync(join(THUMBNAILS_DIR, `${id}_thumb.jpg`)),
|
||||
clip: existsSync(join(THUMBNAILS_DIR, `${id}_clip.mp4`)),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to promisify the screenshots method
|
||||
function runFfmpeg(command: ffmpeg.FfmpegCommand): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
command
|
||||
.on('end', () => resolve())
|
||||
.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
204
src/services/signaling-manager.service.ts
Normal file
204
src/services/signaling-manager.service.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { WebSocket } from 'ws';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
interface DeviceSocket {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
deviceType: string;
|
||||
socket: WebSocket;
|
||||
lastHeartbeat: number;
|
||||
}
|
||||
|
||||
interface SignalingWsMessage {
|
||||
type: 'register_device' | 'list_devices' | 'offer' | 'answer' | 'ice_candidate' | 'heartbeat';
|
||||
deviceId?: string;
|
||||
deviceType?: string;
|
||||
deviceName?: string;
|
||||
to?: string;
|
||||
sdp?: string;
|
||||
sdpType?: string;
|
||||
candidate?: { sdpMid: string; sdpMLineIndex: number; sdp: string };
|
||||
}
|
||||
|
||||
export class SignalingManager {
|
||||
private sockets = new Map<string, DeviceSocket>(); // key = `${userId}:${deviceId}`
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private log: FastifyBaseLogger,
|
||||
) {
|
||||
// Heartbeat check every 30s
|
||||
this.heartbeatInterval = setInterval(() => this.checkHeartbeats(), 30_000);
|
||||
}
|
||||
|
||||
async handleConnection(userId: string, socket: WebSocket) {
|
||||
socket.on('message', async (data: Buffer) => {
|
||||
try {
|
||||
const msg: SignalingWsMessage = JSON.parse(data.toString());
|
||||
await this.handleMessage(userId, socket, msg);
|
||||
} catch (err) {
|
||||
this.log.error({ err }, 'Signaling message error');
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Invalid message' }));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
this.removeSocket(userId, socket);
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
this.removeSocket(userId, socket);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(userId: string, socket: WebSocket, msg: SignalingWsMessage) {
|
||||
switch (msg.type) {
|
||||
case 'register_device': {
|
||||
if (!msg.deviceId) return;
|
||||
const key = `${userId}:${msg.deviceId}`;
|
||||
this.sockets.set(key, {
|
||||
userId,
|
||||
deviceId: msg.deviceId,
|
||||
deviceType: msg.deviceType || 'QUEST',
|
||||
socket,
|
||||
lastHeartbeat: Date.now(),
|
||||
});
|
||||
|
||||
// Upsert device in DB
|
||||
await this.prisma.device.upsert({
|
||||
where: { userId_deviceId: { userId, deviceId: msg.deviceId } },
|
||||
update: {
|
||||
isOnline: true,
|
||||
lastSeen: new Date(),
|
||||
deviceName: msg.deviceName || '',
|
||||
deviceType: msg.deviceType || 'QUEST',
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
deviceId: msg.deviceId,
|
||||
deviceName: msg.deviceName || '',
|
||||
deviceType: msg.deviceType || 'QUEST',
|
||||
isOnline: true,
|
||||
},
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify({ type: 'registered', deviceId: msg.deviceId }));
|
||||
this.log.info({ userId, deviceId: msg.deviceId }, 'Device registered');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'list_devices': {
|
||||
// Return all online devices for this user
|
||||
const devices: { deviceId: string; deviceName: string; userId: string; isOnline: boolean }[] = [];
|
||||
for (const [, ds] of this.sockets) {
|
||||
if (ds.userId === userId) {
|
||||
devices.push({
|
||||
deviceId: ds.deviceId,
|
||||
deviceName: '',
|
||||
userId: ds.userId,
|
||||
isOnline: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
socket.send(JSON.stringify({ type: 'device_list', devices }));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'ice_candidate': {
|
||||
if (!msg.to) return;
|
||||
// Find target socket
|
||||
const targetKey = `${userId}:${msg.to}`;
|
||||
const target = this.sockets.get(targetKey);
|
||||
// Also check if `to` is a deviceId belonging to the same user
|
||||
if (target && target.socket.readyState === WebSocket.OPEN) {
|
||||
target.socket.send(JSON.stringify({
|
||||
type: msg.type,
|
||||
from: this.getDeviceIdForSocket(userId, socket),
|
||||
sdp: msg.sdp,
|
||||
sdpType: msg.sdpType,
|
||||
candidate: msg.candidate,
|
||||
}));
|
||||
} else {
|
||||
// Try to find by just deviceId across all users (for same-user cross-device)
|
||||
for (const [, ds] of this.sockets) {
|
||||
if (ds.deviceId === msg.to && ds.userId === userId && ds.socket !== socket) {
|
||||
if (ds.socket.readyState === WebSocket.OPEN) {
|
||||
ds.socket.send(JSON.stringify({
|
||||
type: msg.type,
|
||||
from: this.getDeviceIdForSocket(userId, socket),
|
||||
sdp: msg.sdp,
|
||||
sdpType: msg.sdpType,
|
||||
candidate: msg.candidate,
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'Target device not found' }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'heartbeat': {
|
||||
const deviceId = msg.deviceId || this.getDeviceIdForSocket(userId, socket);
|
||||
if (deviceId) {
|
||||
const key = `${userId}:${deviceId}`;
|
||||
const ds = this.sockets.get(key);
|
||||
if (ds) ds.lastHeartbeat = Date.now();
|
||||
}
|
||||
socket.send(JSON.stringify({ type: 'heartbeat_ack' }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getDeviceIdForSocket(userId: string, socket: WebSocket): string | undefined {
|
||||
for (const [, ds] of this.sockets) {
|
||||
if (ds.userId === userId && ds.socket === socket) {
|
||||
return ds.deviceId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private removeSocket(userId: string, socket: WebSocket) {
|
||||
for (const [key, ds] of this.sockets) {
|
||||
if (ds.userId === userId && ds.socket === socket) {
|
||||
this.sockets.delete(key);
|
||||
// Mark device offline
|
||||
this.prisma.device.updateMany({
|
||||
where: { userId, deviceId: ds.deviceId },
|
||||
data: { isOnline: false, lastSeen: new Date() },
|
||||
}).catch(() => {});
|
||||
this.log.info({ userId, deviceId: ds.deviceId }, 'Device disconnected');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private checkHeartbeats() {
|
||||
const timeout = 60_000; // 60s
|
||||
const now = Date.now();
|
||||
for (const [key, ds] of this.sockets) {
|
||||
if (now - ds.lastHeartbeat > timeout) {
|
||||
this.log.info({ userId: ds.userId, deviceId: ds.deviceId }, 'Heartbeat timeout');
|
||||
ds.socket.close();
|
||||
this.sockets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
for (const [, ds] of this.sockets) {
|
||||
ds.socket.close();
|
||||
}
|
||||
this.sockets.clear();
|
||||
}
|
||||
}
|
||||
109
src/services/stream-status.service.ts
Normal file
109
src/services/stream-status.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getYouTubeBroadcastStatus, refreshYouTubeToken } from './youtube.service.js';
|
||||
import { isTwitchStreamLive, refreshTwitchToken } from './twitch.service.js';
|
||||
import { decrypt, encrypt } from './crypto.service.js';
|
||||
|
||||
/**
|
||||
* Check service-side broadcast status for LIVE/READY plans and auto-end
|
||||
* plans whose streams have finished on the platform side.
|
||||
*
|
||||
* Works without a userId filter so it can be called from any context
|
||||
* (owner's plan list, public feed, etc.).
|
||||
*/
|
||||
export async function autoDetectEndedPlans(prisma: PrismaClient, plans: any[]) {
|
||||
const tokenCache = new Map<string, string>();
|
||||
|
||||
async function getAccessToken(linkedAccountId: string, service: 'YOUTUBE' | 'TWITCH'): Promise<string | null> {
|
||||
const cached = tokenCache.get(linkedAccountId);
|
||||
if (cached) return cached;
|
||||
|
||||
const account = await prisma.linkedAccount.findFirst({
|
||||
where: { id: linkedAccountId },
|
||||
});
|
||||
if (!account) return null;
|
||||
|
||||
let accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv);
|
||||
|
||||
if (account.tokenExpiresAt < new Date(Date.now() + 60_000)) {
|
||||
const refreshToken = decrypt(account.refreshTokenEnc, account.refreshTokenIv);
|
||||
const result = service === 'YOUTUBE'
|
||||
? await refreshYouTubeToken(refreshToken)
|
||||
: await refreshTwitchToken(refreshToken);
|
||||
accessToken = result.accessToken;
|
||||
const accessEnc = encrypt(accessToken);
|
||||
const updateData: any = {
|
||||
accessTokenEnc: accessEnc.ciphertext,
|
||||
accessTokenIv: accessEnc.iv,
|
||||
tokenExpiresAt: new Date(Date.now() + result.expiresIn * 1000),
|
||||
};
|
||||
if (service === 'TWITCH' && 'refreshToken' in result) {
|
||||
const refreshEnc = encrypt(result.refreshToken as string);
|
||||
updateData.refreshTokenEnc = refreshEnc.ciphertext;
|
||||
updateData.refreshTokenIv = refreshEnc.iv;
|
||||
}
|
||||
await prisma.linkedAccount.update({
|
||||
where: { id: account.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
tokenCache.set(linkedAccountId, accessToken);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function markPlanEnded(plan: any) {
|
||||
for (const dest of plan.destinations) {
|
||||
await prisma.streamDestination.update({
|
||||
where: { id: dest.id },
|
||||
data: { status: 'ENDED' },
|
||||
});
|
||||
}
|
||||
await prisma.streamPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: { status: 'ENDED' },
|
||||
});
|
||||
plan.status = 'ENDED';
|
||||
}
|
||||
|
||||
for (const plan of plans) {
|
||||
if (plan.status !== 'LIVE' && plan.status !== 'READY') continue;
|
||||
|
||||
// Check YouTube broadcast status
|
||||
const ytDest = plan.destinations.find(
|
||||
(d: any) => d.serviceId === 'YOUTUBE' && d.broadcastId,
|
||||
);
|
||||
if (ytDest) {
|
||||
try {
|
||||
const token = await getAccessToken(ytDest.linkedAccountId, 'YOUTUBE');
|
||||
if (token) {
|
||||
const ytStatus = await getYouTubeBroadcastStatus(token, ytDest.broadcastId!);
|
||||
if (ytStatus === 'complete' || ytStatus === 'revoked') {
|
||||
await markPlanEnded(plan);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// Check Twitch stream status
|
||||
const twitchDest = plan.destinations.find(
|
||||
(d: any) => d.serviceId === 'TWITCH' && d.broadcastId,
|
||||
);
|
||||
if (twitchDest) {
|
||||
try {
|
||||
const token = await getAccessToken(twitchDest.linkedAccountId, 'TWITCH');
|
||||
if (token) {
|
||||
const live = await isTwitchStreamLive(token, twitchDest.broadcastId!);
|
||||
if (!live) {
|
||||
await markPlanEnded(plan);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/services/twitch-chat.service.ts
Normal file
176
src/services/twitch-chat.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
export interface TwitchChatMessage {
|
||||
id: string;
|
||||
authorName: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
isModerator: boolean;
|
||||
isBroadcaster: boolean;
|
||||
color: string;
|
||||
badges: string;
|
||||
}
|
||||
|
||||
export class TwitchChatClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private channel: string;
|
||||
private token: string;
|
||||
private nick: string;
|
||||
private connected = false;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(channel: string, token: string, nick: string) {
|
||||
super();
|
||||
this.channel = channel.toLowerCase();
|
||||
this.token = token;
|
||||
this.nick = nick.toLowerCase();
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws) return;
|
||||
|
||||
this.ws = new WebSocket('wss://irc-ws.chat.twitch.tv:443');
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log(`[Twitch-IRC] WebSocket open for #${this.channel}`);
|
||||
if (!this.ws) return;
|
||||
// Request capabilities
|
||||
this.ws.send('CAP REQ :twitch.tv/tags twitch.tv/commands');
|
||||
// Authenticate
|
||||
this.ws.send(`PASS oauth:${this.token}`);
|
||||
this.ws.send(`NICK ${this.nick}`);
|
||||
// Join channel
|
||||
this.ws.send(`JOIN #${this.channel}`);
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: Buffer) => {
|
||||
const raw = data.toString();
|
||||
console.log(`[Twitch-IRC] #${this.channel} << ${raw.trim().substring(0, 200)}`);
|
||||
const lines = raw.split('\r\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
this.handleLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', (code: number, reason: Buffer) => {
|
||||
console.log(`[Twitch-IRC] #${this.channel} closed: ${code} ${reason.toString()}`);
|
||||
this.connected = false;
|
||||
this.cleanup();
|
||||
this.emit('disconnected');
|
||||
});
|
||||
|
||||
this.ws.on('error', (err: Error) => {
|
||||
console.log(`[Twitch-IRC] #${this.channel} error: ${err.message}`);
|
||||
this.emit('error', err);
|
||||
});
|
||||
|
||||
// Keep-alive: send PING every 4 minutes
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send('PING :tmi.twitch.tv');
|
||||
}
|
||||
}, 240_000);
|
||||
}
|
||||
|
||||
sendMessage(text: string): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(`PRIVMSG #${this.channel} :${text}`);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.cleanup();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
// Handle PING
|
||||
if (line.startsWith('PING')) {
|
||||
this.ws?.send('PONG :tmi.twitch.tv');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle successful join / connection
|
||||
if (line.includes('366') || line.includes(':End of /NAMES list')) {
|
||||
if (!this.connected) {
|
||||
this.connected = true;
|
||||
this.emit('connected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle auth failure
|
||||
if (line.includes('NOTICE') && (line.includes('Login unsuccessful') || line.includes('Login authentication failed'))) {
|
||||
this.emit('error', new Error('Twitch login failed — token may be expired or missing chat scopes'));
|
||||
this.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse PRIVMSG with IRCv3 tags
|
||||
if (line.includes('PRIVMSG')) {
|
||||
const msg = this.parsePrivmsg(line);
|
||||
if (msg) {
|
||||
this.emit('message', msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parsePrivmsg(line: string): TwitchChatMessage | null {
|
||||
// Format: @tags :user!user@user.tmi.twitch.tv PRIVMSG #channel :message
|
||||
let tags: Record<string, string> = {};
|
||||
|
||||
let rest = line;
|
||||
if (rest.startsWith('@')) {
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
if (spaceIdx === -1) return null;
|
||||
const tagStr = rest.substring(1, spaceIdx);
|
||||
rest = rest.substring(spaceIdx + 1);
|
||||
|
||||
for (const part of tagStr.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq !== -1) {
|
||||
tags[part.substring(0, eq)] = part.substring(eq + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find message text after "PRIVMSG #channel :"
|
||||
const privmsgIdx = rest.indexOf('PRIVMSG');
|
||||
if (privmsgIdx === -1) return null;
|
||||
const afterPrivmsg = rest.substring(privmsgIdx);
|
||||
const colonIdx = afterPrivmsg.indexOf(' :');
|
||||
if (colonIdx === -1) return null;
|
||||
const text = afterPrivmsg.substring(colonIdx + 2);
|
||||
|
||||
const displayName = tags['display-name'] || this.extractNick(rest);
|
||||
const timestamp = tags['tmi-sent-ts'] ? parseInt(tags['tmi-sent-ts'], 10) : Date.now();
|
||||
const badges = tags['badges'] || '';
|
||||
|
||||
return {
|
||||
id: tags['id'] || `twitch-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
|
||||
authorName: displayName,
|
||||
text,
|
||||
timestamp,
|
||||
isModerator: badges.includes('moderator') || tags['mod'] === '1',
|
||||
isBroadcaster: badges.includes('broadcaster'),
|
||||
color: tags['color'] || '',
|
||||
badges,
|
||||
};
|
||||
}
|
||||
|
||||
private extractNick(line: string): string {
|
||||
// :nick!nick@nick.tmi.twitch.tv PRIVMSG ...
|
||||
const match = line.match(/^:(\w+)!/);
|
||||
return match ? match[1] : 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ const SCOPES = [
|
||||
'channel:manage:broadcast',
|
||||
'channel:read:stream_key',
|
||||
'user:read:email',
|
||||
'chat:read',
|
||||
'chat:edit',
|
||||
].join(' ');
|
||||
|
||||
export function getTwitchAuthUrl(state: string): string {
|
||||
@@ -125,6 +127,28 @@ export async function revokeTwitchToken(accessToken: string): Promise<void> {
|
||||
|
||||
// ── Twitch Helix Stream Management ──────────────────────
|
||||
|
||||
/** Check if a Twitch channel is currently live. Returns true if live, false if offline. */
|
||||
export async function isTwitchStreamLive(
|
||||
accessToken: string,
|
||||
broadcasterId: string,
|
||||
): Promise<boolean> {
|
||||
const res = await fetch(
|
||||
`https://api.twitch.tv/helix/streams?user_id=${broadcasterId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Client-Id': config.twitch.clientId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Twitch get streams failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { data: { type: string }[] };
|
||||
return data.data.length > 0 && data.data[0].type === 'live';
|
||||
}
|
||||
|
||||
export async function getTwitchStreamKey(
|
||||
accessToken: string,
|
||||
broadcasterId: string,
|
||||
|
||||
108
src/services/youtube-chat.service.ts
Normal file
108
src/services/youtube-chat.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
export interface YouTubeChatMessage {
|
||||
id: string;
|
||||
authorName: string;
|
||||
authorImageUrl: string;
|
||||
text: string;
|
||||
publishedAt: string;
|
||||
isModerator: boolean;
|
||||
isChatOwner: boolean;
|
||||
}
|
||||
|
||||
export interface YouTubeChatPollResult {
|
||||
messages: YouTubeChatMessage[];
|
||||
nextPageToken: string;
|
||||
pollingIntervalMillis: number;
|
||||
}
|
||||
|
||||
export async function getYouTubeLiveChatId(
|
||||
accessToken: string,
|
||||
broadcastId: string,
|
||||
): Promise<string | null> {
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,status&id=${broadcastId}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
console.log(`[YT-Chat] liveBroadcasts API error: ${res.status} ${body}`);
|
||||
return null;
|
||||
}
|
||||
const data = (await res.json()) as any;
|
||||
const items = data.items ?? [];
|
||||
if (items.length === 0) {
|
||||
console.log(`[YT-Chat] No broadcast found for id=${broadcastId}`);
|
||||
return null;
|
||||
}
|
||||
const broadcast = items[0];
|
||||
const lifeCycleStatus = broadcast.status?.lifeCycleStatus;
|
||||
const liveChatId = broadcast.snippet?.liveChatId ?? null;
|
||||
console.log(`[YT-Chat] broadcast=${broadcastId} lifeCycleStatus=${lifeCycleStatus} liveChatId=${liveChatId}`);
|
||||
return liveChatId;
|
||||
}
|
||||
|
||||
export async function pollYouTubeChatMessages(
|
||||
accessToken: string,
|
||||
liveChatId: string,
|
||||
pageToken?: string,
|
||||
): Promise<YouTubeChatPollResult> {
|
||||
const params = new URLSearchParams({
|
||||
liveChatId,
|
||||
part: 'snippet,authorDetails',
|
||||
maxResults: '200',
|
||||
});
|
||||
if (pageToken) params.set('pageToken', pageToken);
|
||||
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/liveChat/messages?${params}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`YouTube chat poll failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as any;
|
||||
|
||||
const messages: YouTubeChatMessage[] = (data.items ?? []).map((item: any) => ({
|
||||
id: item.id,
|
||||
authorName: item.authorDetails?.displayName ?? 'Unknown',
|
||||
authorImageUrl: item.authorDetails?.profileImageUrl ?? '',
|
||||
text: item.snippet?.displayMessage ?? '',
|
||||
publishedAt: item.snippet?.publishedAt ?? new Date().toISOString(),
|
||||
isModerator: item.authorDetails?.isChatModerator ?? false,
|
||||
isChatOwner: item.authorDetails?.isChatOwner ?? false,
|
||||
}));
|
||||
|
||||
return {
|
||||
messages,
|
||||
nextPageToken: data.nextPageToken ?? '',
|
||||
pollingIntervalMillis: data.pollingIntervalMillis ?? 5000,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendYouTubeChatMessage(
|
||||
accessToken: string,
|
||||
liveChatId: string,
|
||||
messageText: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
'https://www.googleapis.com/youtube/v3/liveChat/messages?part=snippet',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
snippet: {
|
||||
liveChatId,
|
||||
type: 'textMessageEvent',
|
||||
textMessageDetails: { messageText },
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`YouTube send chat message failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,23 @@ export interface UserProfileResponse {
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
bio: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateProfileBody {
|
||||
displayName?: string;
|
||||
bio?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface PairingGenerateResponse {
|
||||
code: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface PairingRedeemBody {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// ── Providers ────────────────────────────────────────────
|
||||
@@ -39,6 +56,14 @@ export interface LinkedAccountResponse {
|
||||
displayName: string;
|
||||
accountId: string;
|
||||
avatarUrl: string | null;
|
||||
rtmpUrl?: string;
|
||||
streamKey?: string;
|
||||
}
|
||||
|
||||
export interface CreateCustomRtmpBody {
|
||||
displayName: string;
|
||||
rtmpUrl: string;
|
||||
streamKey: string;
|
||||
}
|
||||
|
||||
// ── Streams ──────────────────────────────────────────────
|
||||
@@ -46,6 +71,7 @@ export interface CreateStreamPlanBody {
|
||||
name: string;
|
||||
executionMode?: string;
|
||||
gameId?: string;
|
||||
isPublic?: boolean;
|
||||
destinations: CreateDestinationBody[];
|
||||
}
|
||||
|
||||
@@ -53,6 +79,7 @@ export interface UpdateStreamPlanBody {
|
||||
name?: string;
|
||||
executionMode?: string;
|
||||
gameId?: string;
|
||||
isPublic?: boolean;
|
||||
destinations?: CreateDestinationBody[];
|
||||
}
|
||||
|
||||
@@ -63,6 +90,8 @@ export interface CreateDestinationBody {
|
||||
privacyStatus?: string;
|
||||
gameId?: string;
|
||||
tags?: string;
|
||||
rtmpUrl?: string;
|
||||
streamKey?: string;
|
||||
}
|
||||
|
||||
export interface StreamPlanResponse {
|
||||
@@ -71,6 +100,7 @@ export interface StreamPlanResponse {
|
||||
status: string;
|
||||
executionMode: string;
|
||||
gameId: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
destinations: StreamDestinationResponse[];
|
||||
@@ -103,3 +133,41 @@ export interface PreparedDestination {
|
||||
streamKey: string;
|
||||
broadcastId: string;
|
||||
}
|
||||
|
||||
// ── Social ──────────────────────────────────────────────
|
||||
export interface PublicUserResponse {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
bio: string;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
isFollowing: boolean;
|
||||
streams?: StreamPlanResponse[];
|
||||
}
|
||||
|
||||
export interface FeedItemResponse {
|
||||
plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } };
|
||||
previewUrl: string | null;
|
||||
posterUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
clipUrl: string | null;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
isLiked: boolean;
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
items: FeedItemResponse[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface LikeStatusResponse {
|
||||
count: number;
|
||||
isLiked: boolean;
|
||||
}
|
||||
|
||||
export interface FollowListResponse {
|
||||
users: PublicUserResponse[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user