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
This commit is contained in:
179
package-lock.json
generated
179
package-lock.json
generated
@@ -16,10 +16,13 @@
|
|||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
|
"ffmpeg-static": "^5.2.0",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"jose": "^6.0.8",
|
"jose": "^6.0.8",
|
||||||
"undici": "^7.3.0"
|
"undici": "^7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
@@ -28,6 +31,21 @@
|
|||||||
"vitest": "^3.0.5"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@@ -1197,6 +1215,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.11",
|
"version": "22.19.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||||
@@ -1338,6 +1366,18 @@
|
|||||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
@@ -1381,6 +1421,11 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
@@ -1410,6 +1455,12 @@
|
|||||||
"fastq": "^1.17.1"
|
"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": {
|
"node_modules/c12": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
@@ -1449,6 +1500,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/chai": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
@@ -1502,6 +1559,21 @@
|
|||||||
"consola": "^3.2.3"
|
"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": {
|
"node_modules/confbox": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
@@ -1536,7 +1608,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -1648,6 +1719,15 @@
|
|||||||
"once": "^1.4.0"
|
"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": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
@@ -1884,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": {
|
"node_modules/find-my-way": {
|
||||||
"version": "9.5.0",
|
"version": "9.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
|
||||||
@@ -1898,6 +1994,20 @@
|
|||||||
"node": ">=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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1944,6 +2054,34 @@
|
|||||||
"giget": "dist/cli.mjs"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -1959,6 +2097,12 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -2077,7 +2221,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@@ -2162,6 +2305,11 @@
|
|||||||
"wrappy": "1"
|
"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": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -2326,6 +2474,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pure-rand": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
@@ -2750,6 +2907,12 @@
|
|||||||
"fsevents": "~2.3.3"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -2964,6 +3127,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|||||||
@@ -25,10 +25,13 @@
|
|||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"jose": "^6.0.8",
|
"jose": "^6.0.8",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"ffmpeg-static": "^5.2.0",
|
||||||
"undici": "^7.3.0"
|
"undici": "^7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ model Video {
|
|||||||
videoUrl String
|
videoUrl String
|
||||||
fileSize Int?
|
fileSize Int?
|
||||||
isPublic Boolean @default(true)
|
isPublic Boolean @default(true)
|
||||||
|
sourcePlanId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
likes VideoLike[]
|
likes VideoLike[]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { createChatRoutes } from './routes/chat/websocket.js';
|
|||||||
import { createSignalingRoutes } from './routes/signaling/websocket.js';
|
import { createSignalingRoutes } from './routes/signaling/websocket.js';
|
||||||
import deviceRoutes from './routes/devices/status.js';
|
import deviceRoutes from './routes/devices/status.js';
|
||||||
import videoRoutes from './routes/content/videos.js';
|
import videoRoutes from './routes/content/videos.js';
|
||||||
|
import thumbnailRoutes from './routes/media/thumbnails.js';
|
||||||
import { ChatManager } from './services/chat-manager.service.js';
|
import { ChatManager } from './services/chat-manager.service.js';
|
||||||
import { SignalingManager } from './services/signaling-manager.service.js';
|
import { SignalingManager } from './services/signaling-manager.service.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
@@ -77,6 +78,7 @@ export async function buildApp() {
|
|||||||
await app.register(createSignalingRoutes(signalingManager));
|
await app.register(createSignalingRoutes(signalingManager));
|
||||||
await app.register(deviceRoutes);
|
await app.register(deviceRoutes);
|
||||||
await app.register(videoRoutes);
|
await app.register(videoRoutes);
|
||||||
|
await app.register(thumbnailRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createReadStream, existsSync, mkdirSync, statSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
import { createWriteStream } from 'fs';
|
import { createWriteStream } from 'fs';
|
||||||
|
import { generateMediaAssets } from '../../services/media-processing.service.js';
|
||||||
|
|
||||||
const VIDEOS_DIR = join(process.cwd(), 'data', 'videos');
|
const VIDEOS_DIR = join(process.cwd(), 'data', 'videos');
|
||||||
|
|
||||||
@@ -102,6 +103,9 @@ const videoRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate thumbnails async (fire-and-forget)
|
||||||
|
generateMediaAssets(filePath, video.id, request.log).catch(() => {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
|
|||||||
63
src/routes/media/thumbnails.ts
Normal file
63
src/routes/media/thumbnails.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, createReadStream, statSync } from 'fs';
|
||||||
|
import { THUMBNAILS_DIR } from '../../services/media-processing.service.js';
|
||||||
|
|
||||||
|
const thumbnailRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
|
// GET /media/thumbnails/:filename — serve thumbnail/poster/clip files
|
||||||
|
fastify.get<{ Params: { filename: string } }>('/media/thumbnails/:filename', {
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['filename'],
|
||||||
|
properties: { filename: { type: 'string' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { filename } = request.params;
|
||||||
|
|
||||||
|
// Sanitize filename — only allow alphanumeric, hyphens, underscores, dots
|
||||||
|
if (!/^[\w\-.]+$/.test(filename)) {
|
||||||
|
return reply.status(400).send({ error: 'Invalid filename' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = join(THUMBNAILS_DIR, filename);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return reply.status(404).send({ error: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
|
const contentType = ext === 'jpg' || ext === 'jpeg'
|
||||||
|
? 'image/jpeg'
|
||||||
|
: ext === 'mp4'
|
||||||
|
? 'video/mp4'
|
||||||
|
: 'application/octet-stream';
|
||||||
|
|
||||||
|
reply.header('Content-Type', contentType);
|
||||||
|
reply.header('Content-Length', stat.size);
|
||||||
|
reply.header('Cache-Control', 'public, max-age=3600');
|
||||||
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
|
|
||||||
|
const range = request.headers.range;
|
||||||
|
if (range && contentType === 'video/mp4') {
|
||||||
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
||||||
|
|
||||||
|
if (start >= stat.size || end >= stat.size || start > end) {
|
||||||
|
reply.status(416).header('Content-Range', `bytes */${stat.size}`).send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(206);
|
||||||
|
reply.header('Content-Range', `bytes ${start}-${end}/${stat.size}`);
|
||||||
|
reply.header('Content-Length', end - start + 1);
|
||||||
|
return reply.send(createReadStream(filePath, { start, end }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(createReadStream(filePath));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default thumbnailRoutes;
|
||||||
@@ -3,6 +3,7 @@ import { existsSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { optionalAuth } from '../../middleware/require-auth.js';
|
import { optionalAuth } from '../../middleware/require-auth.js';
|
||||||
import { PREVIEWS_DIR } from '../streams/preview.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 { autoDetectEndedPlans } from '../../services/stream-status.service.js';
|
||||||
import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
|
import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
} else if (filter === 'following' && !request.userId) {
|
} else if (filter === 'following' && !request.userId) {
|
||||||
// Not logged in — return empty following feed
|
// Not logged in — return empty following feed
|
||||||
return { items: [], nextCursor: null } as FeedResponse;
|
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;
|
let orderBy: any;
|
||||||
@@ -95,6 +101,7 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
const feedItems: FeedItemResponse[] = items.map(plan => {
|
const feedItems: FeedItemResponse[] = items.map(plan => {
|
||||||
const hasPreview = existsSync(join(PREVIEWS_DIR, `${plan.id}.mp4`));
|
const hasPreview = existsSync(join(PREVIEWS_DIR, `${plan.id}.mp4`));
|
||||||
|
const assets = hasMediaAssets(plan.id);
|
||||||
return {
|
return {
|
||||||
plan: {
|
plan: {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
@@ -125,6 +132,9 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
user: plan.user,
|
user: plan.user,
|
||||||
},
|
},
|
||||||
previewUrl: hasPreview ? `/streams/plans/${plan.id}/preview` : null,
|
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,
|
likeCount: plan._count.likes,
|
||||||
commentCount: 0, // portal comments are ephemeral via WebSocket
|
commentCount: 0, // portal comments are ephemeral via WebSocket
|
||||||
isLiked: likedSet.has(plan.id),
|
isLiked: likedSet.has(plan.id),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
import { existsSync, unlinkSync } from 'fs';
|
import { existsSync, unlinkSync, copyFileSync, renameSync, statSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { requireAuth } from '../../middleware/require-auth.js';
|
import { requireAuth } from '../../middleware/require-auth.js';
|
||||||
import { AppError } from '../../plugins/error-handler.js';
|
import { AppError } from '../../plugins/error-handler.js';
|
||||||
import { autoDetectEndedPlans } from '../../services/stream-status.service.js';
|
import { autoDetectEndedPlans } from '../../services/stream-status.service.js';
|
||||||
import { decrypt, encrypt } from '../../services/crypto.service.js';
|
import { decrypt, encrypt } from '../../services/crypto.service.js';
|
||||||
import { PREVIEWS_DIR } from './preview.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';
|
import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js';
|
||||||
|
|
||||||
export function formatPlan(plan: any): StreamPlanResponse {
|
export function formatPlan(plan: any): StreamPlanResponse {
|
||||||
@@ -223,13 +224,83 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
});
|
});
|
||||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||||
|
|
||||||
request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan');
|
|
||||||
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
|
|
||||||
|
|
||||||
// Clean up preview file if exists
|
|
||||||
const previewPath = join(PREVIEWS_DIR, `${plan.id}.mp4`);
|
const previewPath = join(PREVIEWS_DIR, `${plan.id}.mp4`);
|
||||||
if (existsSync(previewPath)) {
|
const hasPreview = existsSync(previewPath);
|
||||||
try { unlinkSync(previewPath); } catch { /* ignore */ }
|
|
||||||
|
// 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 });
|
reply.status(200).send({ success: true });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AppError } from '../../plugins/error-handler.js';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { existsSync, mkdirSync, createReadStream, createWriteStream, statSync, unlinkSync } from 'fs';
|
import { existsSync, mkdirSync, createReadStream, createWriteStream, statSync, unlinkSync } from 'fs';
|
||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
|
import { generateMediaAssets } from '../../services/media-processing.service.js';
|
||||||
|
|
||||||
const PREVIEWS_DIR = join(process.cwd(), 'data', 'previews');
|
const PREVIEWS_DIR = join(process.cwd(), 'data', 'previews');
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
@@ -44,6 +45,10 @@ const previewRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
request.log.info({ planId: plan.id }, 'Preview clip uploaded');
|
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 });
|
reply.status(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
112
src/services/media-processing.service.ts
Normal file
112
src/services/media-processing.service.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
|
import ffmpegStatic from 'ffmpeg-static';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
const THUMBNAILS_DIR = join(process.cwd(), 'data', 'thumbnails');
|
||||||
|
|
||||||
|
// Ensure thumbnails directory exists
|
||||||
|
if (!existsSync(THUMBNAILS_DIR)) {
|
||||||
|
mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ffmpeg binary path
|
||||||
|
if (ffmpegStatic) {
|
||||||
|
ffmpeg.setFfmpegPath(ffmpegStatic as unknown as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { THUMBNAILS_DIR };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate poster, thumbnail, and clip from a video file.
|
||||||
|
* Runs async — caller should fire-and-forget.
|
||||||
|
*/
|
||||||
|
export async function generateMediaAssets(
|
||||||
|
inputPath: string,
|
||||||
|
outputId: string,
|
||||||
|
log?: { info: (...args: any[]) => void; error: (...args: any[]) => void },
|
||||||
|
): Promise<void> {
|
||||||
|
const posterPath = join(THUMBNAILS_DIR, `${outputId}_poster.jpg`);
|
||||||
|
const thumbPath = join(THUMBNAILS_DIR, `${outputId}_thumb.jpg`);
|
||||||
|
const clipPath = join(THUMBNAILS_DIR, `${outputId}_clip.mp4`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. First-frame poster (720px wide, JPEG quality 80)
|
||||||
|
await runFfmpeg(
|
||||||
|
ffmpeg(inputPath)
|
||||||
|
.screenshots({
|
||||||
|
count: 1,
|
||||||
|
timemarks: ['0.1'],
|
||||||
|
filename: `${outputId}_poster.jpg`,
|
||||||
|
folder: THUMBNAILS_DIR,
|
||||||
|
size: '720x?',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Square thumbnail (300x300, center-crop)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ffmpeg(inputPath)
|
||||||
|
.seekInput(0.1)
|
||||||
|
.frames(1)
|
||||||
|
.videoFilter('crop=min(iw\\,ih):min(iw\\,ih),scale=300:300')
|
||||||
|
.outputOptions(['-q:v', '5'])
|
||||||
|
.output(thumbPath)
|
||||||
|
.on('end', () => resolve())
|
||||||
|
.on('error', (err) => reject(err))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Short clip (first 3 seconds, 480px wide, 500kbps)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ffmpeg(inputPath)
|
||||||
|
.duration(3)
|
||||||
|
.videoFilter('scale=480:-2')
|
||||||
|
.videoBitrate('500k')
|
||||||
|
.outputOptions(['-movflags', '+faststart'])
|
||||||
|
.output(clipPath)
|
||||||
|
.on('end', () => resolve())
|
||||||
|
.on('error', (err) => reject(err))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
log?.info({ outputId }, 'Media assets generated');
|
||||||
|
} catch (err) {
|
||||||
|
log?.error({ outputId, err }, 'Media asset generation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video duration in seconds via ffprobe.
|
||||||
|
*/
|
||||||
|
export function getVideoDuration(filePath: string): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve(Math.round(metadata.format.duration ?? 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if thumbnail assets exist for a given ID.
|
||||||
|
*/
|
||||||
|
export function hasMediaAssets(id: string): {
|
||||||
|
poster: boolean;
|
||||||
|
thumb: boolean;
|
||||||
|
clip: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
poster: existsSync(join(THUMBNAILS_DIR, `${id}_poster.jpg`)),
|
||||||
|
thumb: existsSync(join(THUMBNAILS_DIR, `${id}_thumb.jpg`)),
|
||||||
|
clip: existsSync(join(THUMBNAILS_DIR, `${id}_clip.mp4`)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to promisify the screenshots method
|
||||||
|
function runFfmpeg(command: ffmpeg.FfmpegCommand): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
command
|
||||||
|
.on('end', () => resolve())
|
||||||
|
.on('error', (err) => reject(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -149,6 +149,9 @@ export interface PublicUserResponse {
|
|||||||
export interface FeedItemResponse {
|
export interface FeedItemResponse {
|
||||||
plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } };
|
plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } };
|
||||||
previewUrl: string | null;
|
previewUrl: string | null;
|
||||||
|
posterUrl: string | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
clipUrl: string | null;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
isLiked: boolean;
|
isLiked: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user