1- import { spawn } from "bun" ;
1+ import { type Subprocess , spawn } from "bun" ;
22
33export interface AudioExtractionOptions {
44 format ?: "mp3" ;
@@ -12,23 +12,91 @@ const DEFAULT_OPTIONS: Required<AudioExtractionOptions> = {
1212 bitrate : "128k" ,
1313} ;
1414
15+ const CHECK_TIMEOUT_MS = 30_000 ;
16+ const EXTRACT_TIMEOUT_MS = 120_000 ;
17+ const MAX_AUDIO_SIZE_BYTES = 100 * 1024 * 1024 ;
18+
19+ let activeProcesses = 0 ;
20+ const MAX_CONCURRENT_PROCESSES = 6 ;
21+
22+ export function getActiveProcessCount ( ) : number {
23+ return activeProcesses ;
24+ }
25+
26+ export function canAcceptNewProcess ( ) : boolean {
27+ return activeProcesses < MAX_CONCURRENT_PROCESSES ;
28+ }
29+
30+ function killProcess ( proc : Subprocess ) : void {
31+ try {
32+ proc . kill ( ) ;
33+ } catch { }
34+ }
35+
36+ async function withTimeout < T > (
37+ promise : Promise < T > ,
38+ timeoutMs : number ,
39+ cleanup ?: ( ) => void ,
40+ ) : Promise < T > {
41+ let timeoutId : ReturnType < typeof setTimeout > | undefined ;
42+ const timeoutPromise = new Promise < never > ( ( _ , reject ) => {
43+ timeoutId = setTimeout ( ( ) => {
44+ cleanup ?.( ) ;
45+ reject ( new Error ( `Operation timed out after ${ timeoutMs } ms` ) ) ;
46+ } , timeoutMs ) ;
47+ } ) ;
48+
49+ try {
50+ const result = await Promise . race ( [ promise , timeoutPromise ] ) ;
51+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
52+ return result ;
53+ } catch ( err ) {
54+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
55+ throw err ;
56+ }
57+ }
58+
1559export async function checkHasAudioTrack ( videoUrl : string ) : Promise < boolean > {
60+ if ( ! canAcceptNewProcess ( ) ) {
61+ throw new Error ( "Server is busy, please try again later" ) ;
62+ }
63+
64+ activeProcesses ++ ;
65+
1666 const proc = spawn ( {
1767 cmd : [ "ffmpeg" , "-i" , videoUrl , "-hide_banner" ] ,
1868 stdout : "pipe" ,
1969 stderr : "pipe" ,
2070 } ) ;
2171
22- const stderrText = await new Response ( proc . stderr ) . text ( ) ;
23- await proc . exited ;
72+ try {
73+ const result = await withTimeout (
74+ ( async ( ) => {
75+ const stderrText = await new Response ( proc . stderr ) . text ( ) ;
76+ await proc . exited ;
77+ return / S t r e a m # \d + : \d + .* A u d i o : / . test ( stderrText ) ;
78+ } ) ( ) ,
79+ CHECK_TIMEOUT_MS ,
80+ ( ) => killProcess ( proc ) ,
81+ ) ;
2482
25- return / S t r e a m # \d + : \d + .* A u d i o : / . test ( stderrText ) ;
83+ return result ;
84+ } finally {
85+ activeProcesses -- ;
86+ killProcess ( proc ) ;
87+ }
2688}
2789
2890export async function extractAudio (
2991 videoUrl : string ,
3092 options : AudioExtractionOptions = { } ,
3193) : Promise < Uint8Array > {
94+ if ( ! canAcceptNewProcess ( ) ) {
95+ throw new Error ( "Server is busy, please try again later" ) ;
96+ }
97+
98+ activeProcesses ++ ;
99+
32100 const opts = { ...DEFAULT_OPTIONS , ...options } ;
33101
34102 const ffmpegArgs = [
@@ -51,23 +119,53 @@ export async function extractAudio(
51119 stderr : "pipe" ,
52120 } ) ;
53121
54- const [ stdout , stderrText , exitCode ] = await Promise . all ( [
55- new Response ( proc . stdout ) . arrayBuffer ( ) ,
56- new Response ( proc . stderr ) . text ( ) ,
57- proc . exited ,
58- ] ) ;
122+ try {
123+ const result = await withTimeout (
124+ ( async ( ) => {
125+ const [ stdout , stderrText , exitCode ] = await Promise . all ( [
126+ new Response ( proc . stdout ) . arrayBuffer ( ) ,
127+ new Response ( proc . stderr ) . text ( ) ,
128+ proc . exited ,
129+ ] ) ;
130+
131+ if ( exitCode !== 0 ) {
132+ throw new Error ( `FFmpeg exited with code ${ exitCode } : ${ stderrText } ` ) ;
133+ }
134+
135+ if ( stdout . byteLength > MAX_AUDIO_SIZE_BYTES ) {
136+ throw new Error (
137+ `Audio too large: ${ stdout . byteLength } bytes exceeds ${ MAX_AUDIO_SIZE_BYTES } byte limit` ,
138+ ) ;
139+ }
140+
141+ return new Uint8Array ( stdout ) ;
142+ } ) ( ) ,
143+ EXTRACT_TIMEOUT_MS ,
144+ ( ) => killProcess ( proc ) ,
145+ ) ;
59146
60- if ( exitCode !== 0 ) {
61- throw new Error ( `FFmpeg exited with code ${ exitCode } : ${ stderrText } ` ) ;
147+ return result ;
148+ } finally {
149+ activeProcesses -- ;
150+ killProcess ( proc ) ;
62151 }
152+ }
63153
64- return new Uint8Array ( stdout ) ;
154+ export interface StreamingExtractResult {
155+ stream : ReadableStream < Uint8Array > ;
156+ cleanup : ( ) => void ;
65157}
66158
67- export async function extractAudioStream (
159+ export function extractAudioStream (
68160 videoUrl : string ,
69161 options : AudioExtractionOptions = { } ,
70- ) : Promise < ReadableStream < Uint8Array > > {
162+ ) : StreamingExtractResult {
163+ if ( ! canAcceptNewProcess ( ) ) {
164+ throw new Error ( "Server is busy, please try again later" ) ;
165+ }
166+
167+ activeProcesses ++ ;
168+
71169 const opts = { ...DEFAULT_OPTIONS , ...options } ;
72170
73171 const ffmpegArgs = [
@@ -90,5 +188,52 @@ export async function extractAudioStream(
90188 stderr : "pipe" ,
91189 } ) ;
92190
93- return proc . stdout as ReadableStream < Uint8Array > ;
191+ let timeoutId : ReturnType < typeof setTimeout > | undefined ;
192+ let cleaned = false ;
193+
194+ const cleanup = ( ) => {
195+ if ( cleaned ) return ;
196+ cleaned = true ;
197+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
198+ activeProcesses -- ;
199+ killProcess ( proc ) ;
200+ } ;
201+
202+ timeoutId = setTimeout ( ( ) => {
203+ console . error ( "[ffmpeg] Stream extraction timed out" ) ;
204+ cleanup ( ) ;
205+ } , EXTRACT_TIMEOUT_MS ) ;
206+
207+ proc . exited . then ( ( code ) => {
208+ if ( code !== 0 ) {
209+ console . error ( `[ffmpeg] Stream extraction exited with code ${ code } ` ) ;
210+ }
211+ cleanup ( ) ;
212+ } ) ;
213+
214+ const originalStream = proc . stdout as ReadableStream < Uint8Array > ;
215+
216+ const wrappedStream = new ReadableStream < Uint8Array > ( {
217+ async start ( controller ) {
218+ const reader = originalStream . getReader ( ) ;
219+ try {
220+ while ( true ) {
221+ const { done, value } = await reader . read ( ) ;
222+ if ( done ) break ;
223+ controller . enqueue ( value ) ;
224+ }
225+ controller . close ( ) ;
226+ } catch ( err ) {
227+ controller . error ( err ) ;
228+ } finally {
229+ reader . releaseLock ( ) ;
230+ cleanup ( ) ;
231+ }
232+ } ,
233+ cancel ( ) {
234+ cleanup ( ) ;
235+ } ,
236+ } ) ;
237+
238+ return { stream : wrappedStream , cleanup } ;
94239}
0 commit comments