fix video embeds (#12)

This commit is contained in:
Lexie
2024-09-30 10:41:01 +02:00
committed by GitHub
parent c0046ec249
commit d63326651c
12 changed files with 135 additions and 279 deletions

View File

@@ -10,6 +10,11 @@ Embed Bluesky links in Discord.
#### Simply append `x` at the end of `bsky.app`.
## FAQ
### [Video is too long to embed] in an embed description.
Due to Discord's and BlueSky limitations, the video cannot be embedded if it exceeds ~30s. You can still view the video by clicking on the `VixBluesky` in the author field.
## Authors
- [@ItsRauf](https://www.github.com/ItsRauf) - Original author

View File

@@ -2,6 +2,8 @@ FROM node:22-alpine
WORKDIR /app
RUN apk add ffmpeg
COPY package*.json ./
RUN npm i -g pnpm && pnpm install

View File

@@ -11,12 +11,9 @@
"author": "Lexedia",
"license": "ISC",
"dependencies": {
"fastify": "^4.28.1",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3"
"fastify": "^4.28.1"
},
"devDependencies": {
"@types/fluent-ffmpeg": "^2.1.26",
"@types/node": "^22.5.4",
"pino-pretty": "^11.2.2",
"typescript": "^5.6.2"

181
pkgs/api/pnpm-lock.yaml generated
View File

@@ -11,16 +11,7 @@ importers:
fastify:
specifier: ^4.28.1
version: 4.28.1
ffmpeg-static:
specifier: ^5.2.0
version: 5.2.0
fluent-ffmpeg:
specifier: ^2.1.3
version: 2.1.3
devDependencies:
'@types/fluent-ffmpeg':
specifier: ^2.1.26
version: 2.1.26
'@types/node':
specifier: ^22.5.4
version: 22.5.4
@@ -33,10 +24,6 @@ importers:
packages:
'@derhuerst/http-basic@8.2.4':
resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
engines: {node: '>=6.0.0'}
'@fastify/ajv-compiler@3.6.0':
resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==}
@@ -49,12 +36,6 @@ packages:
'@fastify/merge-json-schemas@0.1.1':
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
'@types/fluent-ffmpeg@2.1.26':
resolution: {integrity: sha512-0JVF3wdQG+pN0ImwWD0bNgJiKF2OHg/7CDBHw5UIbRTvlnkgGHK6V5doE54ltvhud4o31/dEiHm23CAlxFiUQg==}
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@22.5.4':
resolution: {integrity: sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==}
@@ -65,10 +46,6 @@ packages:
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -88,9 +65,6 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
async@0.2.10:
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -101,22 +75,12 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
@@ -124,22 +88,9 @@ packages:
dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
debug@4.3.7:
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
end-of-stream@1.4.4:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
@@ -185,18 +136,10 @@ packages:
fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
ffmpeg-static@5.2.0:
resolution: {integrity: sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==}
engines: {node: '>=16'}
find-my-way@8.2.0:
resolution: {integrity: sha512-HdWXgFYc6b1BJcOBDBwjqWuHJj1WYiqrxSh25qtU4DabpMFdj/gSunNBQb83t+8Zt67D7CXEzJWTkxaShMTMOA==}
engines: {node: '>=14'}
fluent-ffmpeg@2.1.3:
resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==}
engines: {node: '>=18'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -204,26 +147,13 @@ packages:
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
http-response-object@3.0.2:
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@@ -240,9 +170,6 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
@@ -250,9 +177,6 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
parse-cache-control@1.0.1:
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
pino-abstract-transport@1.2.0:
resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==}
@@ -277,10 +201,6 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -291,10 +211,6 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readable-stream@4.5.2:
resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -360,9 +276,6 @@ packages:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript@5.6.2:
resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==}
engines: {node: '>=14.17'}
@@ -371,25 +284,11 @@ packages:
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
snapshots:
'@derhuerst/http-basic@8.2.4':
dependencies:
caseless: 0.12.0
concat-stream: 2.0.0
http-response-object: 3.0.2
parse-cache-control: 1.0.1
'@fastify/ajv-compiler@3.6.0':
dependencies:
ajv: 8.17.1
@@ -406,12 +305,6 @@ snapshots:
dependencies:
fast-deep-equal: 3.1.3
'@types/fluent-ffmpeg@2.1.26':
dependencies:
'@types/node': 22.5.4
'@types/node@10.17.60': {}
'@types/node@22.5.4':
dependencies:
undici-types: 6.19.8
@@ -422,12 +315,6 @@ snapshots:
abstract-logging@2.0.1: {}
agent-base@6.0.2:
dependencies:
debug: 4.3.7
transitivePeerDependencies:
- supports-color
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -443,8 +330,6 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
async@0.2.10: {}
atomic-sleep@1.0.0: {}
avvio@8.4.0:
@@ -454,38 +339,21 @@ snapshots:
base64-js@1.5.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
caseless@0.12.0: {}
colorette@2.0.20: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
cookie@0.6.0: {}
dateformat@4.6.3: {}
debug@4.3.7:
dependencies:
ms: 2.1.3
end-of-stream@1.4.4:
dependencies:
once: 1.4.0
env-paths@2.2.1: {}
event-target-shim@5.0.1: {}
events@3.3.0: {}
@@ -543,49 +411,20 @@ snapshots:
dependencies:
reusify: 1.0.4
ffmpeg-static@5.2.0:
dependencies:
'@derhuerst/http-basic': 8.2.4
env-paths: 2.2.1
https-proxy-agent: 5.0.1
progress: 2.0.3
transitivePeerDependencies:
- supports-color
find-my-way@8.2.0:
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
safe-regex2: 3.1.0
fluent-ffmpeg@2.1.3:
dependencies:
async: 0.2.10
which: 1.3.1
forwarded@0.2.0: {}
help-me@5.0.0: {}
http-response-object@3.0.2:
dependencies:
'@types/node': 10.17.60
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.3.7
transitivePeerDependencies:
- supports-color
ieee754@1.2.1: {}
inherits@2.0.4: {}
ipaddr.js@1.9.1: {}
isexe@2.0.0: {}
joycon@3.1.1: {}
json-schema-ref-resolver@1.0.1:
@@ -602,16 +441,12 @@ snapshots:
minimist@1.2.8: {}
ms@2.1.3: {}
on-exit-leak-free@2.1.2: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
parse-cache-control@1.0.1: {}
pino-abstract-transport@1.2.0:
dependencies:
readable-stream: 4.5.2
@@ -656,8 +491,6 @@ snapshots:
process@0.11.10: {}
progress@2.0.3: {}
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -670,12 +503,6 @@ snapshots:
quick-format-unescaped@4.0.4: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readable-stream@4.5.2:
dependencies:
abort-controller: 3.0.0
@@ -726,16 +553,8 @@ snapshots:
toad-cache@3.7.0: {}
typedarray@0.0.6: {}
typescript@5.6.2: {}
undici-types@6.19.8: {}
util-deprecate@1.0.2: {}
which@1.3.1:
dependencies:
isexe: 2.0.0
wrappy@1.0.2: {}

View File

@@ -1,5 +1,5 @@
import fastify from "fastify";
import { tsToMpeg4 } from "./utils";
import { bufferVideo } from "./utils";
const envToLogger = {
development: {
@@ -26,14 +26,19 @@ declare global {
}
}
const { NODE_ENV: env = "development" } = process.env;
const { NODE_ENV: env = "development", PORT } = process.env;
const app = fastify({ logger: envToLogger[env] });
app.addContentTypeParser("*", (_, __, done) => done(null));
app.get("/", async (_, res) => res.redirect("https://bskyx.app"));
// serve 0 bytes favicon so browsers don't spam the server
app.get("/favicon.ico", (_, res) => res.send(""));
app.get<{ Params: { "*": string } }>(
"/generate/*",
"/*",
{
schema: {
params: {
@@ -46,41 +51,27 @@ app.get<{ Params: { "*": string } }>(
},
},
async (req, res) => {
// Idk anymore
let urls = Buffer.from(req.params["*"], 'base64').toString().split(";");
const payload = Buffer.from(req.params["*"], "base64")
.toString()
.split(";");
// Remove .mp4 extension if it exists
if (urls.at(-1)?.endsWith(".mp4")) {
urls = urls.slice(0, -1);
urls.push(urls.at(-1)?.slice(0, -4) as string);
}
const [did, id, quality] = payload;
urls = urls.map((url) => decodeURIComponent(url)).map((url) => url.replaceAll('\uFFFD', ''));
const url = `https://video.bsky.app/watch/${did}/${id}/${quality}/video.m3u8`;
const result = await Promise.allSettled(
urls.map((url) => fetch(url).then((res) => res.arrayBuffer()))
);
try {
bufferVideo(url, res);
if (result.some((res) => res.status === "rejected")) {
res.header("Content-Type", "video/mp4");
res.header("Cache-Control", "public, max-age=604800");
return res;
} catch (error) {
console.error(error);
res.status(400).send({ error: "Failed to fetch video" });
return;
}
const buffers = result.map((res) =>
res.status === "fulfilled" ? new Uint8Array(res.value) : new Uint8Array(0)
);
const video = await tsToMpeg4(buffers);
// const fileName = `video-${new Date().toUTCString()}.mp4`;
res.header("Content-Type", "video/mp4");
res.header("Cache-Control", "public, max-age=604800");
// res.header("Content-Disposition", `attachment; filename=${fileName}`);
res.send(video);
}
);
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
const port = PORT ? Number(PORT) : 3000;
app.listen({ port, host: "0.0.0.0" });

View File

@@ -1,52 +1,53 @@
import ffmpeg from "fluent-ffmpeg";
import ffmpegPath from "ffmpeg-static";
import { type ChildProcess, spawn as s } from "node:child_process";
import { PassThrough } from "node:stream";
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { type FastifyReply } from "fastify";
import type { Writable, Readable } from "node:stream";
ffmpeg.setFfmpegPath(ffmpegPath as string);
export function bufferVideo(masterUrl: string, res: FastifyReply) {
let process: ChildProcess;
const cleanup = () => {
process?.kill("SIGTERM");
setTimeout(() => process?.kill("SIGKILL"), 5000);
res.raw.end();
};
export function tsToMpeg4(buffers: Uint8Array[]): Promise<Buffer> {
return new Promise((res, rej) => {
const input = new PassThrough();
const args = [
"-loglevel",
"-8",
"-i",
masterUrl,
"-c:v",
"copy",
"-c:a",
"aac",
"-bsf:a",
"aac_adtstoasc",
"-movflags",
"faststart+frag_keyframe+empty_moov",
"-f",
"mp4",
"pipe:3",
];
buffers.forEach((b) => input.write(b));
input.end();
const tempFilePath = path.join(tmpdir(), `output-${Date.now()}.mp4`);
ffmpeg(input)
.outputOption(
"-c",
"copy",
"-movflags",
"faststart",
"-preset",
"ultrafast"
)
.on("end", async () => {
try {
const ob = await fs.readFile(tempFilePath);
await fs.unlink(tempFilePath);
res(ob);
} catch (e) {
rej(e);
}
})
.on("error", async (err, stdout, stderr) => {
console.error("Error:", err.message);
console.error("ffmpeg stdout:", stdout);
console.error("ffmpeg stderr:", stderr);
try {
await fs.unlink(tempFilePath);
} catch (e) {
rej(e);
}
rej(err);
})
.save(tempFilePath);
process = s("ffmpeg", args, {
windowsHide: true,
stdio: ["inherit", "inherit", "inherit", "pipe"],
});
const [, , , stream] = process.stdio;
pipe(stream, res.raw, cleanup);
process.on("close", cleanup);
process.on("exit", cleanup);
res.raw.on("finish", cleanup);
return stream;
}
function pipe(
from: Readable | Writable | undefined | null,
to: NodeJS.WritableStream,
cleanup: () => void
) {
from?.on("error", cleanup).on("close", cleanup);
to.on("error", cleanup).on("close", cleanup);
from?.pipe(to);
}

View File

@@ -5,7 +5,7 @@ import { OEmbedTypes } from "../routes/getOEmbed";
import { parseEmbedImages } from "../lib/parseEmbedImages";
import { parseEmbedDescription } from "../lib/parseEmbedDescription";
import { StreamInfo } from "../lib/processVideoEmbed";
import { join } from "../lib/utils";
import { checkType, join } from "../lib/utils";
interface PostProps {
post: AppBskyFeedDefs.PostView;
@@ -21,6 +21,16 @@ const Meta = ({ post }: { post: AppBskyFeedDefs.PostView }) => (
</>
);
const constructVideoUrl = (streamInfo: StreamInfo, apiUrl: string) => {
const url = new URL(streamInfo.masterUri);
const [did, id, quality] = url.pathname.split("/").slice(2);
const parts = [did, id, quality];
return `${apiUrl}generate/${btoa(join(parts, ";"))}.mp4`;
};
const Video = ({
streamInfo,
apiUrl,
@@ -34,8 +44,7 @@ const Video = ({
post: AppBskyFeedDefs.PostView;
description: string;
}) => {
// Discord can't handle query params in the URL, so i have to do this 🔥beautiful mess🔥
const url = `${apiUrl}generate/${btoa(join(streamInfo.uri, ";"))}.mp4`;
const url = constructVideoUrl(streamInfo, apiUrl);
return (
<>
@@ -109,7 +118,21 @@ export const Post = ({
}: PostProps) => {
const images = parseEmbedImages(post);
const isAuthor = images === post.author.avatar;
const description = parseEmbedDescription(post);
let description = parseEmbedDescription(post);
const isVideo = checkType(
"app.bsky.embed.video",
post.embed?.media ?? post.embed
);
const streamInfo = videoMetadata?.at(-1);
const isTooLong = isVideo && streamInfo!.uri.length > 4;
const shouldOverrideForVideo = isVideo && isTooLong;
let videoUrl;
if (isVideo && isTooLong) {
videoUrl = constructVideoUrl(streamInfo!, apiUrl);
description += `\n[Video is too long to embed!]`;
}
return (
<Layout url={url}>
@@ -125,23 +148,24 @@ export const Post = ({
/>
<meta property="og:updated_time" content={post.indexedAt} />
<meta property="article:published_time" content={post.indexedAt} />
{/* <meta property="og:image" content={post.author.avatar} /> */}
{!isAuthor && <Meta post={post} />}
{images.length !== 0 && !videoMetadata && <Images images={images} />}
{images.length !== 0 && (shouldOverrideForVideo || !isVideo) && (
<Images images={images} />
)}
{videoMetadata && (
{isVideo && streamInfo!.uri.length <= 4 && (
<Video
apiUrl={apiUrl}
streamInfo={videoMetadata.at(-1)!}
streamInfo={streamInfo!}
appDomain={appDomain}
description={description}
post={post}
/>
)}
{!videoMetadata && (
{(shouldOverrideForVideo || !isVideo) && (
<link
rel="alternate"
type="application/json+oembed"
@@ -151,7 +175,9 @@ export const Post = ({
post.likeCount
}&avatar=${encodeURIComponent(
post.author.avatar ?? ""
)}&description=${encodeURIComponent(description)}`}
)}&description=${encodeURIComponent(description)}${
videoUrl ? `&videoUrl=${encodeURIComponent(videoUrl)}` : ""
}`}
/>
)}
</Layout>

View File

@@ -4,6 +4,7 @@ import {
AppBskyFeedDefs,
AppBskyFeedPost,
} from "@atproto/api";
import { indent } from "./utils";
export function parseEmbedDescription(post: AppBskyFeedDefs.PostView) {
if (AppBskyFeedPost.isRecord(post.record)) {
@@ -15,7 +16,7 @@ export function parseEmbedDescription(post: AppBskyFeedDefs.PostView) {
);
if (isViewRecord) {
// @ts-expect-error For some reason the original post value is typed as {}
return `${post.record.text}\n\nQuoting @${post.embed.record.author.handle}\n➥ ${post.embed.record.value.text}`;
return `${post.record.text}\n\nQuoting @${post.embed.record.author.handle}\n➥${indent(post.embed.record.value.text, 2)}`;
}
}
}
@@ -29,7 +30,7 @@ export function parseEmbedDescription(post: AppBskyFeedDefs.PostView) {
);
if (isViewRecord) {
// @ts-expect-error For some reason the original post value is typed as {}
return `${post.record.text}\n\nQuoting @${post.embed.record.record.author.handle}\n➥ ${post.embed.record.record.value.text}`;
return `${post.record.text}\n\nQuoting @${post.embed.record.record.author.handle}\n➥${indent(post.embed.record.record.value.text, 2)}`;
}
}
}

View File

@@ -8,6 +8,7 @@ export interface StreamInfo {
};
codecs: string;
uri: string | string[];
masterUri: string;
}
export interface M3U8Data {
@@ -66,6 +67,7 @@ async function parseM3U8(
},
codecs: "",
uri: "",
masterUri: "",
};
for (const attrib of attribs) {
@@ -88,6 +90,8 @@ async function parseM3U8(
} else if (line.includes("m3u8")) {
const resolvedUrl = `${initalUrl}/${line}`;
streams.at(-1)!.masterUri = resolvedUrl;
const cont = await fetch(resolvedUrl).then((res) => res.text());
const parsed = await parseM3U8(removeLastPathSegment(resolvedUrl), cont);
@@ -102,6 +106,7 @@ async function parseM3U8(
},
codecs: "",
uri: `${initalUrl}/${line}`,
masterUri: initalUrl,
});
} else {
// Discard

View File

@@ -12,4 +12,10 @@ export const join = (t: string | string[], s: string) =>
Array.isArray(t) ? t.join(s) : t;
export const checkType = (t: string, o: any) =>
(typeof o.$type === "string" && o.$type.startsWith(t)) || o.$type === t;
(typeof o?.$type === "string" && o?.$type.startsWith(t)) || o?.$type === t;
export const indent = (s: string, n: number) =>
s
.split("\n")
.map((l) => " ".repeat(n) + l)
.join("\n");

View File

@@ -23,11 +23,12 @@ export const getOEmbed: Handler<Env, "/oembed"> = async (c) => {
}
if (type === OEmbedTypes.Post) {
const { replies, reposts, likes } = c.req.query();
const { replies, reposts, likes, videoUrl } = c.req.query();
return c.json({
author_name: `🗨️ ${replies} ♻️ ${reposts} 💙 ${likes}`,
...defaults,
author_name: `🗨️ ${replies} ♻️ ${reposts} 💙 ${likes}`,
provider_url: videoUrl ? videoUrl : defaults.provider_url,
});
}
if (type === OEmbedTypes.Profile) {

View File

@@ -2,7 +2,7 @@ import { Handler } from "hono";
import { HTTPException } from "hono/http-exception";
import { fetchPost } from "../lib/fetchPostData";
import { Post } from "../components/Post";
import { processVideoEmbed, StreamInfo, VideoMedia } from "../lib/processVideoEmbed";
import { processVideoEmbed, StreamInfo } from "../lib/processVideoEmbed";
import { checkType } from "../lib/utils";
export const getPost: Handler<
@@ -27,8 +27,10 @@ export const getPost: Handler<
checkType("app.bsky.embed.video", fetchedPost.embed) ||
checkType("app.bsky.embed.video", fetchedPost.embed?.media)
) {
// @ts-expect-error
videoMetaData = await processVideoEmbed(fetchedPost.embed?.media || fetchedPost.embed);
videoMetaData = await processVideoEmbed(
// @ts-expect-error
fetchedPost.embed?.media ?? fetchedPost.embed
);
}
return c.html(