22
33use regex:: Regex ;
44use serde:: Serialize ;
5- use std:: path:: PathBuf ;
5+ use std:: path:: { Path , PathBuf } ;
6+ use std:: process:: Command ;
67use std:: time:: { SystemTime , UNIX_EPOCH } ;
8+ use tauri:: Manager ;
79
810// ---------------------------------------------------------------------------
911// Constantes — API privada de Threads (reverse-engineered, sin autenticación)
@@ -77,6 +79,116 @@ struct VideoDownloadResult {
7779 source : & ' static str ,
7880}
7981
82+ fn first_existing_path ( candidates : & [ PathBuf ] ) -> Option < PathBuf > {
83+ candidates. iter ( ) . find ( |path| path. exists ( ) ) . cloned ( )
84+ }
85+
86+ fn resolve_sidecar_binary ( app : & tauri:: AppHandle , base_name : & str ) -> Option < PathBuf > {
87+ #[ cfg( target_os = "windows" ) ]
88+ let file_name = format ! ( "{base_name}.exe" ) ;
89+ #[ cfg( not( target_os = "windows" ) ) ]
90+ let file_name = base_name. to_string ( ) ;
91+
92+ let mut candidates: Vec < PathBuf > = Vec :: new ( ) ;
93+
94+ // Dev: src-tauri/target/debug/{app}.exe -> ../../bin/{binary}
95+ if let Ok ( exe_path) = std:: env:: current_exe ( ) {
96+ if let Some ( exe_dir) = exe_path. parent ( ) {
97+ candidates. push ( exe_dir. join ( ".." ) . join ( ".." ) . join ( "bin" ) . join ( & file_name) ) ;
98+ }
99+ }
100+
101+ // Bundle: resources directory in installed app.
102+ if let Ok ( resource_dir) = app. path ( ) . resource_dir ( ) {
103+ candidates. push ( resource_dir. join ( "bin" ) . join ( & file_name) ) ;
104+ candidates. push ( resource_dir. join ( & file_name) ) ;
105+ }
106+
107+ // CWD fallback (useful for custom launches/tests).
108+ if let Ok ( cwd) = std:: env:: current_dir ( ) {
109+ candidates. push ( cwd. join ( "src-tauri" ) . join ( "bin" ) . join ( & file_name) ) ;
110+ candidates. push ( cwd. join ( "bin" ) . join ( & file_name) ) ;
111+ }
112+
113+ first_existing_path ( & candidates)
114+ }
115+
116+ fn extract_downloaded_file_from_stdout ( stdout : & str ) -> Option < String > {
117+ stdout
118+ . lines ( )
119+ . map ( str:: trim)
120+ . filter ( |line| !line. is_empty ( ) )
121+ . last ( )
122+ . map ( |line| line. to_string ( ) )
123+ }
124+
125+ fn file_name_from_path ( path : & Path ) -> String {
126+ path. file_name ( )
127+ . and_then ( |name| name. to_str ( ) )
128+ . unwrap_or ( "video.mp4" )
129+ . to_string ( )
130+ }
131+
132+ fn download_with_yt_dlp (
133+ app : & tauri:: AppHandle ,
134+ post_url : & str ,
135+ output_prefix : & str ,
136+ download_dir : & Path ,
137+ ) -> Result < VideoDownloadResult , String > {
138+ let yt_dlp = resolve_sidecar_binary ( app, "yt-dlp" )
139+ . ok_or_else ( || String :: from ( "No se encontro yt-dlp sidecar en recursos/bin." ) ) ?;
140+ let ffmpeg = resolve_sidecar_binary ( app, "ffmpeg" ) ;
141+
142+ let output_template = format ! ( "{output_prefix}.%(ext)s" ) ;
143+ let mut command = Command :: new ( & yt_dlp) ;
144+ command
145+ . arg ( post_url)
146+ . arg ( "--no-playlist" )
147+ . arg ( "--no-warnings" )
148+ . arg ( "--newline" )
149+ . arg ( "--restrict-filenames" )
150+ . arg ( "--merge-output-format" )
151+ . arg ( "mp4" )
152+ . arg ( "--print" )
153+ . arg ( "after_move:filepath" )
154+ . arg ( "-P" )
155+ . arg ( download_dir)
156+ . arg ( "-o" )
157+ . arg ( output_template) ;
158+
159+ if let Some ( ffmpeg_path) = ffmpeg {
160+ command. arg ( "--ffmpeg-location" ) . arg ( ffmpeg_path) ;
161+ }
162+
163+ let output = command
164+ . output ( )
165+ . map_err ( |e| format ! ( "No se pudo ejecutar yt-dlp: {e}" ) ) ?;
166+
167+ if !output. status . success ( ) {
168+ let stderr = String :: from_utf8_lossy ( & output. stderr ) . trim ( ) . to_string ( ) ;
169+ let stdout = String :: from_utf8_lossy ( & output. stdout ) . trim ( ) . to_string ( ) ;
170+ let detail = if !stderr. is_empty ( ) { stderr } else { stdout } ;
171+ return Err ( format ! (
172+ "yt-dlp termino con error (exit {}). {}" ,
173+ output. status. code( ) . unwrap_or( -1 ) ,
174+ detail
175+ ) ) ;
176+ }
177+
178+ let stdout = String :: from_utf8_lossy ( & output. stdout ) ;
179+ let downloaded_path = extract_downloaded_file_from_stdout ( & stdout)
180+ . ok_or_else ( || String :: from ( "yt-dlp finalizo sin reportar la ruta del archivo." ) ) ?;
181+ let file_path = PathBuf :: from ( & downloaded_path) ;
182+ let file_name = file_name_from_path ( & file_path) ;
183+
184+ Ok ( VideoDownloadResult {
185+ file_path : downloaded_path,
186+ file_name,
187+ download_dir : download_dir. to_string_lossy ( ) . to_string ( ) ,
188+ source : "yt-dlp-fallback" ,
189+ } )
190+ }
191+
80192// ---------------------------------------------------------------------------
81193// shortcode_to_post_id
82194//
@@ -511,7 +623,7 @@ fn open_url(url: String) -> Result<(), String> {
511623
512624#[ tauri:: command]
513625async fn download_threads_video (
514- _app : tauri:: AppHandle ,
626+ app : tauri:: AppHandle ,
515627 post_url : String ,
516628) -> Result < VideoDownloadResult , String > {
517629 if !is_supported_threads_url ( & post_url) {
@@ -527,10 +639,9 @@ async fn download_threads_video(
527639 . map ( |duration| duration. as_secs ( ) )
528640 . unwrap_or ( 0 ) ;
529641 let output_prefix = format ! ( "threadsvault-{shortcode}-{unix_ts}" ) ;
530- // Flujo principal: usa el mismo resolver completo de la app
531- // (GraphQL + fallback HTML) para evitar depender del extractor de yt-dlp.
642+ // Metodo principal: resolver interno (GraphQL + HTML) + descarga directa.
532643 let resolved = resolve_threads_video ( post_url. clone ( ) ) . await ?;
533- if let Some ( video_url) = resolved. download_url . or ( resolved. playable_url ) {
644+ if let Some ( video_url) = resolved. download_url . clone ( ) . or ( resolved. playable_url . clone ( ) ) {
534645 let direct_output = download_dir. join ( format ! ( "{output_prefix}.mp4" ) ) ;
535646
536647 let client = reqwest:: Client :: builder ( )
@@ -541,48 +652,41 @@ async fn download_threads_video(
541652 . build ( )
542653 . map_err ( |e| e. to_string ( ) ) ?;
543654
544- let response = client
655+ let direct_response = client
545656 . get ( & video_url)
546657 . header ( "Referer" , "https://www.threads.net/" )
547658 . header ( "Accept" , "*/*" )
548659 . send ( )
549- . await
550- . map_err ( |e| format ! ( "No se pudo iniciar la descarga directa: {e}" ) ) ?;
551-
552- if !response. status ( ) . is_success ( ) {
553- return Err ( format ! (
554- "No se pudo descargar el stream directo (HTTP {})." ,
555- response. status( )
556- ) ) ;
660+ . await ;
661+
662+ if let Ok ( response) = direct_response {
663+ if response. status ( ) . is_success ( ) {
664+ if let Ok ( body) = response. bytes ( ) . await {
665+ if std:: fs:: write ( & direct_output, & body) . is_ok ( ) {
666+ let file_name = file_name_from_path ( & direct_output) ;
667+ return Ok ( VideoDownloadResult {
668+ file_path : direct_output. to_string_lossy ( ) . to_string ( ) ,
669+ file_name,
670+ download_dir : download_dir. to_string_lossy ( ) . to_string ( ) ,
671+ source : "direct" ,
672+ } ) ;
673+ }
674+ }
675+ }
557676 }
558-
559- let body = response
560- . bytes ( )
561- . await
562- . map_err ( |e| format ! ( "Error leyendo datos del video: {e}" ) ) ?;
563-
564- std:: fs:: write ( & direct_output, & body) . map_err ( |e| e. to_string ( ) ) ?;
565-
566- let file_name = direct_output
567- . file_name ( )
568- . and_then ( |name| name. to_str ( ) )
569- . unwrap_or ( "video.mp4" )
570- . to_string ( ) ;
571-
572- return Ok ( VideoDownloadResult {
573- file_path : direct_output. to_string_lossy ( ) . to_string ( ) ,
574- file_name,
575- download_dir : download_dir. to_string_lossy ( ) . to_string ( ) ,
576- source : "seal-plus" ,
577- } ) ;
578677 }
579678
580- Err ( format ! (
581- "No se pudo resolver una URL de video descargable para este post. {}" ,
582- resolved
583- . reason
584- . unwrap_or_else( || String :: from( "Threads no expone stream publico en este caso." ) )
585- ) )
679+ // Fallback automatico: extractor robusto de yt-dlp sidecar.
680+ match download_with_yt_dlp ( & app, & post_url, & output_prefix, & download_dir) {
681+ Ok ( result) => Ok ( result) ,
682+ Err ( fallback_error) => Err ( format ! (
683+ "Descarga directa y fallback fallaron. {}. Detalle fallback: {}" ,
684+ resolved
685+ . reason
686+ . unwrap_or_else( || String :: from( "Threads no expone stream publico en este caso." ) ) ,
687+ fallback_error
688+ ) ) ,
689+ }
586690}
587691
588692// ---------------------------------------------------------------------------
0 commit comments