diff --git a/Dockerfile b/Dockerfile index cef522e..a75b4fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,9 @@ RUN pnpm build FROM base AS runner WORKDIR /app +# ffmpeg is used to extract video covers (required for video streaming) +RUN apk add --no-cache ffmpeg + ENV NODE_ENV production COPY --from=builder /app . diff --git a/README.md b/README.md index 9e41c07..4997059 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ powered by [cobalt](https://github.com/imputnet/cobalt). pnpm install ``` - populate `.env` with required env variables based on `.env.example` +- make sure `ffmpeg` is available in `PATH` (used to generate video covers, + otherwise videos won't be streamable in some Telegram clients) - build the code ```bash pnpm build diff --git a/package.json b/package.json index 5e15083..cf8c541 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "@fluent/bundle": "^0.19.1", "@fluent/langneg": "^0.7.0", "@fuman/fetch": "^0.5.0", - "@mtcute/dispatcher": "^0.30.1", - "@mtcute/node": "^0.30.1", + "@mtcute/dispatcher": "^0.32.1", + "@mtcute/node": "^0.32.1", "@t3-oss/env-core": "^0.13.11", "better-sqlite3": "^12.10.0", "drizzle-orm": "^0.45.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c3a527..e1c83da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,11 +18,11 @@ importers: specifier: ^0.5.0 version: 0.5.0 '@mtcute/dispatcher': - specifier: ^0.30.1 - version: 0.30.1 + specifier: ^0.32.1 + version: 0.32.1 '@mtcute/node': - specifier: ^0.30.1 - version: 0.30.1 + specifier: ^0.32.1 + version: 0.32.1 '@t3-oss/env-core': specifier: ^0.13.11 version: 0.13.11(typescript@6.0.3)(zod@4.4.3) @@ -872,14 +872,14 @@ packages: tough-cookie: optional: true - '@fuman/io@0.0.19': - resolution: {integrity: sha512-B+2n3GVa9PCYMJ9xfsdXUlUV9yXO4gKLYfxm815PeJ+MGOw5TbEp166drRmBq1AtxVnP0efy6Oz9rYpKVODgow==} + '@fuman/io@0.0.21': + resolution: {integrity: sha512-8tSqmAUBWFdYrQUednl3I3VxLudaVXfyCqmPyiaDAXgmABTnjD2LvbXoJ7StrV9bvlrwVmwE+smwiKXSJtji0g==} - '@fuman/net@0.0.19': - resolution: {integrity: sha512-yISM+JcZEWBpBYn0v2mUY/Zst4SsicTRaVTvRkVhMiZhgMzdXalfvRwRV/vsgwwL31bntwowCTDW4iilCJLbXg==} + '@fuman/net@0.0.21': + resolution: {integrity: sha512-4PbDA2f6SfR5CtOs7omZNrrRh1X23KQBI0XmpL/r1fVd726H6+IfhDRxm/fgNKlLgMwe6pGbI/hzt8JU0y0sgw==} - '@fuman/node@0.0.19': - resolution: {integrity: sha512-1VNTBb47yrN5BzuXiP4t6An7mDPklH5N+vUtkeL3XATK+xWbtlQsSsU244T7iqGurmDpYrLM9kIUjdMFm8OhDw==} + '@fuman/node@0.0.21': + resolution: {integrity: sha512-5WS/REgANmPKoqWNAEkEeiw6DlU/7spCOGJ1s+p1eMetkwHlJFA5ZEeoIR9outisBD54EUZxAbAkWIdSbVXK5Q==} peerDependencies: ws: ^8.18.1 peerDependenciesMeta: @@ -889,6 +889,9 @@ packages: '@fuman/utils@0.0.19': resolution: {integrity: sha512-4qVrZ9AjKYztLJsNr1Tp7kL48b22dvVLN1iVW+Me8ZSQ0ILN0qknoxjsczVPReF7+GDWgknNxR2l6ggrA4SZyw==} + '@fuman/utils@0.0.21': + resolution: {integrity: sha512-2N5bPK18uGgZRans+ZQ33SE+xSGj4BZNnJbSUnGdH/i4ADTdz/ziC/16Qy3/Jqzbrwr21IpuKObnELp8DDfkDQ==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -922,29 +925,29 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mtcute/core@0.30.1': - resolution: {integrity: sha512-9mppVRIiC/h8PI8Jo+KhBihk4xR0DwKt16Q9PDUNDcsr4OutmDAK+GCXTzTp2e1MZ/iMMFlJ8PE6mjlQSGSnyg==} + '@mtcute/core@0.32.1': + resolution: {integrity: sha512-bkUlAmpAupqwqEHZ5dDtPTkh2p8j9ksSuBb771Mc8qJ73ygrsV4qNll0b84K/tXi4n2nQ2uUuJ2NlY1bjfSiSw==} - '@mtcute/dispatcher@0.30.1': - resolution: {integrity: sha512-+ZEKFHeZonqXDpW7PRCuWSKf9hRitgx3L+9Nqf0WCOU2LuGNIT7I04HSaK3OqZyf805D44VOGOEsfes+MwS91g==} + '@mtcute/dispatcher@0.32.1': + resolution: {integrity: sha512-H6y1Bf9i56pwbBMf1gStykvfQkr5dX8VcPVLIE+yWR3usXMmitDOJ0Jd14VM8EPOIbQQfh+sKdCHoK44YoUcrg==} - '@mtcute/file-id@0.29.0': - resolution: {integrity: sha512-kfNFKjdfFoo9YIl823EJ068u0djgK6MsqfIHIcb6iwmKOxnCFTLRvBGnJUaO+SbOejg63Tz5lzQxKnt03kEV6A==} + '@mtcute/file-id@0.32.0': + resolution: {integrity: sha512-afg/w6KKEn85xenuHVF4N8ysWy3vYZ7VF2sseUM3OXzLdZgVifRnNTdVUmU2kilpRYsR/B0Qvyu7Sif2N7H47A==} - '@mtcute/html-parser@0.30.1': - resolution: {integrity: sha512-j/eosnkgDAUSKojIU7ArOvnZuIIpgGlsTDJep1r1WcdTYSzbAzKOi6usMT4JvyS6EKEbu1tu+KpJf14BHlcPbQ==} + '@mtcute/html-parser@0.32.1': + resolution: {integrity: sha512-Yc+LnnvYUmiy3IBzhBsPSkow0oP+B4VbVheJMKUMJhIjO1jbpHOdkrPFov07wssxdD0C2FSleWSzLcjXgag+RA==} - '@mtcute/markdown-parser@0.30.1': - resolution: {integrity: sha512-QYeRMp4tzg0ruBc1dMnooK2M64js0y1r8xi2BlGtFsco/utFHBpEGsas6ckGkQlHfq6Dtdwn5SePe+GYqZP/CA==} + '@mtcute/markdown-parser@0.32.1': + resolution: {integrity: sha512-gAydSBK+uYK3G/rIbef03q1nDpIlSCNPyCsskvXChqSAB0fqerPw1O/SjJTPZ044GFLu/cIuisti5HqgtwZ4dg==} - '@mtcute/node@0.30.1': - resolution: {integrity: sha512-MqG+St3sTivPzI093bM2OI0YD7KrrKJcAbXwUDGc04L1gKwbZHcc4QZPLlhCFKGxsxAsotSVmuhpPq+AmXIhfQ==} + '@mtcute/node@0.32.1': + resolution: {integrity: sha512-OnWftgebYDT95oID1SNmS/iWXKmvu2wcB8cUDst1TT51Z9pm3EvkDmxOXD9K8RwfNQJIH9DmUOMVV92dPVLXYA==} - '@mtcute/tl-runtime@0.29.0': - resolution: {integrity: sha512-VhDVYKYeC5TypdgzJ/6CtoZgwlR3h1xKatVTnuVcAe1sXrbIwju0EzZlPbUCjH6vgoOXyrJvkhUXdax/oSSjpg==} + '@mtcute/tl-runtime@0.32.0': + resolution: {integrity: sha512-9TdepMheddBismZDYVng9a0m+D4TqUPWcFc83JiArsXv+pmQiNWzXcoyoTUODUA35i6VOIdO5CuNd8hnKoZHpg==} - '@mtcute/wasm@0.29.0': - resolution: {integrity: sha512-PFZ60ufUIt3BW1FFkCqcaEvEcezaTK+KyMVs3A/aXA19aFhDpu6HCSI8FJ6iOfGsIMGaNGYrwH3CZ7XxLn24XA==} + '@mtcute/wasm@0.32.0': + resolution: {integrity: sha512-Hkp+h7OYN1GPpJ3HYeQfBM/OgvF0oWCuS2OY0shwigQmNJib2gX7fIH+KDd5UsRNuHptWASawMjKA+wLnGpSvw==} '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} @@ -3489,23 +3492,25 @@ snapshots: '@fuman/utils': 0.0.19 '@standard-schema/spec': 1.1.0 - '@fuman/io@0.0.19': + '@fuman/io@0.0.21': dependencies: - '@fuman/utils': 0.0.19 + '@fuman/utils': 0.0.21 - '@fuman/net@0.0.19': + '@fuman/net@0.0.21': dependencies: - '@fuman/io': 0.0.19 - '@fuman/utils': 0.0.19 + '@fuman/io': 0.0.21 + '@fuman/utils': 0.0.21 - '@fuman/node@0.0.19': + '@fuman/node@0.0.21': dependencies: - '@fuman/io': 0.0.19 - '@fuman/net': 0.0.19 - '@fuman/utils': 0.0.19 + '@fuman/io': 0.0.21 + '@fuman/net': 0.0.21 + '@fuman/utils': 0.0.21 '@fuman/utils@0.0.19': {} + '@fuman/utils@0.0.21': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3536,57 +3541,57 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mtcute/core@0.30.1': + '@mtcute/core@0.32.1': dependencies: - '@fuman/io': 0.0.19 - '@fuman/net': 0.0.19 - '@fuman/utils': 0.0.19 - '@mtcute/file-id': 0.29.0 - '@mtcute/tl-runtime': 0.29.0 + '@fuman/io': 0.0.21 + '@fuman/net': 0.0.21 + '@fuman/utils': 0.0.21 + '@mtcute/file-id': 0.32.0 + '@mtcute/tl-runtime': 0.32.0 '@types/events': 3.0.0 long: 5.3.2 - '@mtcute/dispatcher@0.30.1': + '@mtcute/dispatcher@0.32.1': dependencies: - '@fuman/utils': 0.0.19 - '@mtcute/core': 0.30.1 + '@fuman/utils': 0.0.21 + '@mtcute/core': 0.32.1 - '@mtcute/file-id@0.29.0': + '@mtcute/file-id@0.32.0': dependencies: - '@fuman/utils': 0.0.19 - '@mtcute/tl-runtime': 0.29.0 + '@fuman/utils': 0.0.21 + '@mtcute/tl-runtime': 0.32.0 long: 5.3.2 - '@mtcute/html-parser@0.30.1': + '@mtcute/html-parser@0.32.1': dependencies: - '@mtcute/core': 0.30.1 + '@mtcute/core': 0.32.1 htmlparser2: 10.1.0 long: 5.3.2 - '@mtcute/markdown-parser@0.30.1': + '@mtcute/markdown-parser@0.32.1': dependencies: - '@mtcute/core': 0.30.1 + '@mtcute/core': 0.32.1 long: 5.3.2 - '@mtcute/node@0.30.1': + '@mtcute/node@0.32.1': dependencies: - '@fuman/net': 0.0.19 - '@fuman/node': 0.0.19 - '@fuman/utils': 0.0.19 - '@mtcute/core': 0.30.1 - '@mtcute/html-parser': 0.30.1 - '@mtcute/markdown-parser': 0.30.1 - '@mtcute/wasm': 0.29.0 + '@fuman/net': 0.0.21 + '@fuman/node': 0.0.21 + '@fuman/utils': 0.0.21 + '@mtcute/core': 0.32.1 + '@mtcute/html-parser': 0.32.1 + '@mtcute/markdown-parser': 0.32.1 + '@mtcute/wasm': 0.32.0 better-sqlite3: 12.10.0 transitivePeerDependencies: - ws - '@mtcute/tl-runtime@0.29.0': + '@mtcute/tl-runtime@0.32.0': dependencies: - '@fuman/utils': 0.0.19 + '@fuman/utils': 0.0.21 long: 5.3.2 - '@mtcute/wasm@0.29.0': {} + '@mtcute/wasm@0.32.0': {} '@ota-meshi/ast-token-store@0.3.0': {} diff --git a/src/core/utils/video.ts b/src/core/utils/video.ts new file mode 100644 index 0000000..2e3a2f8 --- /dev/null +++ b/src/core/utils/video.ts @@ -0,0 +1,92 @@ +import { Buffer } from "node:buffer" +import { spawn } from "node:child_process" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const maxConcurrentCovers = 2 +const maxQueuedCovers = 8 +const coverTimeout = 10_000 + +let activeCovers = 0 +const coverQueue: (() => void)[] = [] + +async function acquireCoverSlot(signal: AbortSignal): Promise { + if (signal.aborted) + return false + if (activeCovers < maxConcurrentCovers) { + activeCovers++ + return true + } + if (coverQueue.length >= maxQueuedCovers) + return false + + return new Promise((resolve) => { + const onAvailable = () => { + signal.removeEventListener("abort", onAbort) + resolve(true) + } + function onAbort() { + const index = coverQueue.indexOf(onAvailable) + if (index !== -1) + coverQueue.splice(index, 1) + resolve(false) + } + coverQueue.push(onAvailable) + signal.addEventListener("abort", onAbort, { once: true }) + }) +} + +function releaseCoverSlot() { + const next = coverQueue.shift() + // Transfer the occupied slot directly to the next waiter. + if (next) + next() + else + activeCovers-- +} + +export async function extractVideoCover(file: Uint8Array, duration?: number): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), coverTimeout) + let acquired = false + let directory: string | undefined + try { + acquired = await acquireCoverSlot(controller.signal) + if (!acquired) + return undefined + + controller.signal.throwIfAborted() + directory = await mkdtemp(join(tmpdir(), "cobold-cover-")) + const path = join(directory, "video") + // MP4 files with a trailing moov atom need a seekable input. + await writeFile(path, file, { signal: controller.signal }) + controller.signal.throwIfAborted() + return await runFfmpegCover(path, duration, controller.signal) + } catch { + // Covers are optional, including when ffmpeg or temporary storage is unavailable. + return undefined + } finally { + clearTimeout(timeout) + if (directory) + await rm(directory, { recursive: true, force: true }).catch(() => { /* noop */ }) + if (acquired) + releaseCoverSlot() + } +} + +async function runFfmpegCover(path: string, duration: number | undefined, signal: AbortSignal): Promise { + const seek = duration !== undefined && duration > 2 ? "1" : "0" + return await new Promise((resolve) => { + const ffmpeg = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-ss", seek, "-i", path, "-frames:v", "1", "-vf", "scale=640:-2", "-pix_fmt", "yuvj420p", "-f", "mjpeg", "pipe:1"], { + stdio: ["ignore", "pipe", "ignore"], + signal, + killSignal: "SIGKILL", + }) + const chunks: Buffer[] = [] + ffmpeg.stdout.on("data", chunk => chunks.push(chunk)) + ffmpeg.on("error", () => { /* handled on close, including abort and spawn errors */ }) + // Wait for exit before deleting the input and releasing the process slot. + ffmpeg.on("close", code => resolve(!signal.aborted && code === 0 && chunks.length ? new Uint8Array(Buffer.concat(chunks)) : undefined)) + }) +} diff --git a/src/telegram/helpers/handler.ts b/src/telegram/helpers/handler.ts index 4c1bbd3..dc9b27f 100644 --- a/src/telegram/helpers/handler.ts +++ b/src/telegram/helpers/handler.ts @@ -15,6 +15,7 @@ import { finishRequest, outputOptions } from "@/core/data/request" import { error, ok } from "@/core/utils/result" import { translatable } from "@/core/utils/text" import { urlWithAuthSchema } from "@/core/utils/url" +import { extractVideoCover } from "@/core/utils/video" import { env } from "@/telegram/helpers/env" export const OutputButton = new CallbackDataBuilder("dl", "output", "request") @@ -93,10 +94,14 @@ async function analyze(buffer: DownloadedMediaContent): Promise async function fileToInputMedia(file: DownloadedMediaContent, fileName?: string, sendAsFile?: boolean): Promise { const analyzedData: AnalysisResult = sendAsFile ? { type: "document" } : await analyze(file) + const isStreamableVideo = analyzedData.type === "video" && !analyzedData.isAnimated + const cover = isStreamableVideo ? await extractVideoCover(file, analyzedData.duration) : undefined // FIXME: hack around mtcute limitation, a better solution should be implemented const fixedFilename = fileName?.endsWith(".jpeg") ? `${fileName.slice(0, -5)}.jpg` : fileName return { ...analyzedData, + supportsStreaming: isStreamableVideo, + cover: cover ? { type: "photo", file: cover } : undefined, fileName: fixedFilename, file, }