Skip to content

Commit 6364136

Browse files
committed
feat(video): add automatic yt-dlp fallback with progress-stage messaging
1 parent 9027efc commit 6364136

4 files changed

Lines changed: 160 additions & 52 deletions

File tree

src-tauri/src/main.rs

Lines changed: 144 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
use regex::Regex;
44
use serde::Serialize;
5-
use std::path::PathBuf;
5+
use std::path::{Path, PathBuf};
6+
use std::process::Command;
67
use 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]
513625
async 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
// ---------------------------------------------------------------------------

src-tauri/tauri.conf.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@
3030
"targets": "all",
3131
"publisher": "D4vRAM369",
3232
"resources": [
33-
"bin/yt-dlp.exe",
34-
"bin/ffmpeg.exe",
35-
"bin/yt-dlp",
36-
"bin/ffmpeg"
33+
"bin/*"
3734
],
3835
"icon": [
3936
"icons/32x32.png",

src/lib/utils/desktop-video.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export interface DesktopVideoDownloadResult {
99
filePath: string
1010
fileName: string
1111
downloadDir: string
12-
source: 'seal-plus'
12+
source: 'direct' | 'yt-dlp-fallback'
1313
}
1414

1515
export function isTauriEnvironment(): boolean {

src/routes/PostDetailScreen.svelte

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -359,19 +359,24 @@
359359
360360
if (isTauriEnvironment()) {
361361
let fakeProgress = 6
362+
let elapsedTicks = 0
362363
const timer = setInterval(() => {
364+
elapsedTicks += 1
363365
fakeProgress = Math.min(fakeProgress + Math.floor(Math.random() * 6 + 2), 92)
366+
const isFallbackStage = elapsedTicks >= 10
364367
inlineVideoDownloadState = {
365368
...inlineVideoDownloadState,
366369
[media.id]: {
367370
...(inlineVideoDownloadState[media.id] ?? { status: 'downloading' }),
368371
status: 'downloading',
369372
progress: fakeProgress,
370-
detail: fakeProgress < 25
371-
? 'Resolviendo stream de Threads...'
372-
: fakeProgress < 60
373-
? 'Conectando con el servidor de media...'
374-
: 'Descargando archivo de video...',
373+
detail: isFallbackStage
374+
? 'Reintentando con fallback (yt-dlp)...'
375+
: fakeProgress < 25
376+
? 'Resolviendo stream de Threads...'
377+
: fakeProgress < 60
378+
? 'Intentando descarga directa...'
379+
: 'Procesando descarga...',
375380
},
376381
}
377382
}, 700)
@@ -402,7 +407,9 @@
402407
filePath: result.filePath,
403408
fileName: result.fileName,
404409
progress: 100,
405-
detail: `Guardado en ${result.filePath}`,
410+
detail: result.source === 'yt-dlp-fallback'
411+
? `Descarga completada con fallback yt-dlp. Guardado en ${result.filePath}`
412+
: `Descarga directa completada. Guardado en ${result.filePath}`,
406413
},
407414
}
408415
} catch (error) {
@@ -1045,7 +1052,7 @@
10451052
font-family: var(--font-display);
10461053
"
10471054
>
1048-
{getInlineVideoDownloadState(media).status === 'downloading' ? 'Descargando...' : 'Descargar (Seal+)'}
1055+
{getInlineVideoDownloadState(media).status === 'downloading' ? 'Descargando...' : 'Descargar video'}
10491056
</button>
10501057
{/if}
10511058

0 commit comments

Comments
 (0)