Compare commits

..

10 Commits

Author SHA1 Message Date
a814dd3387 Add media processing, mine feed filter, and preserve ended streams as videos
- Add ffmpeg-based media asset generation (poster, thumbnail, clip) for previews and videos
- Add GET /media/thumbnails/:filename serving route
- Add filter=mine to feed endpoint for user's own published streams
- Feed response now includes posterUrl, thumbnailUrl, clipUrl
- Deleting an ENDED plan with preview preserves it as a Video record
- Add sourcePlanId to Video schema
2026-03-04 21:07:18 +01:00
36dce50b64 Add Device/Video models, signaling WebSocket, device and content routes
- Prisma: Device model (Quest/Phone, online status, battery, storage,
  game/streaming/cortex state), Video model with likes, VideoLike
- Signaling WebSocket for SDP/ICE relay and device presence
- Device routes: list, status, delete
- Content routes: video CRUD with range-support streaming
- SignalingManager service for device socket registry and heartbeat
2026-03-04 14:41:15 +01:00
7e99a053da Extract autoDetectEndedPlans to shared service, add to feed endpoint 2026-03-04 10:52:01 +01:00
b4ab9c6cf9 Auto-detect ended Twitch streams via Helix API polling 2026-03-04 09:51:04 +01:00
bc6c01940a Per-stream visibility: isPublic on StreamPlan, PATCH endpoint, feed + profile updates 2026-03-03 21:37:00 +01:00
ed83c651d8 Pairing code auth, replace Facebook OAuth, public feed
- Add PairingCode model, POST /generate + /redeem + GET /status endpoints
- Remove facebookId from User, make metaId non-nullable
- Delete meta-web routes, link routes, meta-web-auth service
- Remove metaWeb config block and hasFacebookLink from responses
- Add optionalAuth middleware, make feed publicly accessible
- Resolve Twitch channel names for embed broadcastIds
2026-03-02 23:07:24 +01:00
7ce1c2a8bc Portal backend: Facebook OAuth, social features, portal comments
- Facebook Login OAuth (meta-web auth service + routes)
- Account linking (merge Quest metaId + Facebook facebookId)
- User profile updates (bio, isPublic, displayName)
- Social endpoints: follow/unfollow, feed (trending/following/recent), likes
- Portal comments via WebSocket (subscribe_portal, send_portal_comment)
- Prisma migration: Follow, Like models, facebookId/bio/isPublic on User
- Provider OAuth source=web redirect support for portal callbacks
- Docker compose portal service, CORS multi-origin support
2026-03-02 12:32:39 +01:00
6931670a1f Resilient prepare, Twitch chat echo, parallel chat startup
- Prepare endpoint wraps each destination in try/catch; partial success
  if at least one destination is ready (e.g., Twitch works when YouTube
  is rate-limited)
- Echo sent Twitch messages back to app WebSocket (IRC doesn't echo
  your own PRIVMSGs)
- Start YouTube and Twitch chat clients in parallel via Promise.allSettled
- Fix Twitch auth failure detection (Login unsuccessful + Login
  authentication failed)
- Add Twitch IRC debug logging
2026-03-02 09:40:15 +01:00
cc8ab2320b YouTube/Twitch live chat backend WebSocket proxy
- YouTube chat polling via liveBroadcasts + liveChat/messages APIs
- Twitch IRC WebSocket client with IRCv3 tag parsing
- ChatManager orchestrator with token refresh, retry logic
- WebSocket endpoint at /chat/ws with JWT auth
- Added chat:read, chat:edit to Twitch OAuth scopes
2026-03-01 22:19:19 +01:00
08cca68086 Custom RTMP saved accounts, CUSTOM destination prepare, debug logging
- Add POST /providers/accounts/custom-rtmp endpoint for saved RTMP servers
- Encrypt rtmpUrl/streamKey in accessTokenEnc/refreshTokenEnc fields
- Decrypt and return rtmpUrl/streamKey in GET /providers/accounts for CUSTOM_RTMP
- Skip token revocation on DELETE for CUSTOM_RTMP accounts
- Decrypt CUSTOM_RTMP credentials into CUSTOM destinations on plan create/update
- Handle CUSTOM destinations in prepare lifecycle (already READY, skip provider auth)
- Add debug logging for plan operations and user upsert
2026-03-01 10:50:28 +01:00
36 changed files with 3813 additions and 188 deletions

View File

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

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

View File

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

View File

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

View File

@@ -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])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

View File

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