IDM no tiene scrapers por sitio. Su modelo real es:
1. Browser extension monitorea TODAS las peticiones HTTP
2. Cuando detecta una descarga (por content-type, extensión, o tamaño):
→ Captura: URL + Method + Headers (User-Agent, Referer, Cookie, etc.)
→ Lo envía a IDM engine
3. IDM engine descarga con:
→ Multi-hilo (particiona el archivo)
→ Resume automático
→ Programación
4. El engine NO parsea HTML — la extensión ya le dio la URL directa
Los scrapers site-specific de IDM (para MediaFire, etc.) son solo un extra menor — NO son el core.
La extensión de Chrome es la que captura las URLs. El CLI/engine solo necesita:
- Descargar una URL directa (con headers personalizados)
- Hacerlo bien: multi-hilo, resume, progreso, timeouts
- Aceptar URLs desde cualquier fuente: extensión, CLI manual, Tauri GUI
Los extractors site-specific (MediaFire, etc.) son plugins opcionales para cuando pegas una URL de página web — no al revés.
| Componente | Rol | Problema |
|---|---|---|
scripts/darkdm-mediafire (bash) |
Script específico para MediaFire | Bash frágil, sinute, engine no compartido |
native-host/src/bin/cli.rs (Rust) |
Descarga HLS (.m3u8) | Solo HLS, llama a ffmpeg/curl |
native-host/src/server.rs (Rust) |
HTTP server para extensión Chrome | Usa curl, no tiene progreso real |
native-host/src/downloader.rs (Rust) |
Engine HLS/DASH con fetch_url | Usa curl, no reqwest |
Ninguno descarga genéricamente bien — todos tienen limitaciones.
-
Engine de descarga universal (Rust nativo) que:
- Descarga cualquier URL directa (video, audio, binario, documento)
- Multi-hilo (particionado de archivos)
- Resume automático con Range headers
- Headers personalizados (User-Agent, Referer, Cookie)
- Timeout granular (por conexión + total)
- Callbacks de progreso (para terminal y GUI)
- Extracción automática de archives (ZIP/RAR/7z/tar)
-
CLI unificado
darkdm <url>que:- Usa el engine directamente
- Detecta automáticamente el tipo de contenido
- Acepta headers custom (
--referer,--user-agent,--cookie) - Barra de progreso en terminal
- Output JSON para integraciones
-
Site-extractors como plugins (no como core):
- Si la URL devuelve HTML (no un archivo), el CLI intenta extraer el link real
- Estrategias de extracción genéricas:
- Buscar
<video>/<audio>/<source>en la página - Buscar enlaces con extensiones de archivo comunes
- Detectar scripts con configuraciones de video (window.NUXT, etc.)
- Buscar
- Extractores específicos (MediaFire, Mega, Google Drive) son plugins opcionales
-
Integración con extensión Chrome:
- La extensión captura la URL + headers → engine descarga
- Sin scraping — la extensión ya hizo el trabajo
-
Engine compartido entre CLI, Tauri app, y HTTP server
- GUI (lo maneja Svelte/Tauri)
- Captura de tráfico de red (SSLKEYLOGFILE)
- DRM (Netflix, Disney+, etc.)
- Torrents / magnet links
- Soporte Windows/macOS (por ahora)
┌─────────────────────────────────────────┐
│ darkdm CLI / Tauri App │
│ │
│ $ darkdm "https://ejemplo.com/video" │
│ $ darkdm "https://mediafire.com/..." │
└──────────┬──────────────────────────────┘
│
┌──────────▼──────────────────────────────┐
│ Download Orchestrator │
│ │
│ 1. ¿Ya es URL directa? │
│ → saltar a paso 3 │
│ │
│ 2. ¿Es página web? Intentar extraer: │
│ ┌─────────────────────────────┐ │
│ │ Page Analyzer (genérico) │ │
│ │ • Busca <video>/<audio> src │ │
│ │ • Busca .mp4/.mkv/.avi href │ │
│ │ • Busca .m3u8/.mpd manifest │ │
│ │ • Busca window.__data__ etc │ │
│ └─────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┐ │
│ │ Site Plugins (opcional) │ │
│ │ • MediaFire: download link │ │
│ │ • Mega: api key + decrypt │ │
│ │ • Google Drive: confirm dl │ │
│ └─────────────────────────────┘ │
│ │
│ 3. Descargar URL directa: │
│ ┌─────────────────────────────┐ │
│ │ Download Engine │ │
│ │ • reqwest streaming │ │
│ │ • Multi-hilo (particionado) │ │
│ │ • Resume (Range headers) │ │
│ │ • Headers custom │ │
│ └─────────────────────────────┘ │
│ │
│ 4. ¿Es archive? Extraer: │
│ ┌─────────────────────────────┐ │
│ │ Extractor │ │
│ │ • ZIP (zip crate) │ │
│ │ • RAR (unrar CLI) │ │
│ │ • 7z (7z CLI) │ │
│ │ • tar.gz/xz (tar crate) │ │
│ └─────────────────────────────┘ │
└──────────────────────────────────────────┘
Cada descarga pasa por una cadena de stages independientes. Cada stage hace una cosa y solo una.
┌──────────┐ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 1. URL │ │ 2. Link │ │ 3. Queue │ │ 4. Down- │ │ 5. Post │
│ Resolver │───►│ Extractor │───►│ Manager │───►│ load │───►│ Processor│
└──────────┘ └───────────┘ └──────────┘ │ Engine │ └──────────┘
└──────────┘
/// Cada stage implementa este trait
#[async_trait]
pub trait Stage: Send + Sync {
fn name(&self) -> &'static str;
async fn execute(&self, ctx: &mut DownloadContext) -> Result<(), StageError>;
}
/// Contexto compartido entre stages
#[derive(Debug)]
pub struct DownloadContext {
pub id: Uuid,
pub url: String,
pub resolved_url: Option<String>, // después de redirects
pub detected_links: Vec<DetectedLink>,
pub selected_link: Option<DetectedLink>,
pub download_path: Option<PathBuf>,
pub extracted_files: Vec<PathBuf>,
pub state: DownloadState,
pub options: DownloadOptions,
pub attempt: u8,
pub error: Option<DownloadError>,
}
/// Pipeline ejecuta stages en orden
pub struct Pipeline {
stages: Vec<Box<dyn Stage>>,
}
impl Pipeline {
pub fn new(stages: Vec<Box<dyn Stage>>) -> Self {
Self { stages }
}
pub async fn execute(&self, ctx: &mut DownloadContext) -> PipelineResult {
for stage in &self.stages {
ctx.state = state_for_stage(stage.name());
match stage.execute(ctx).await {
Ok(()) => continue,
Err(e) if e.is_retryable() && ctx.attempt < MAX_RETRIES => {
ctx.attempt += 1;
let delay = backoff_duration(ctx.attempt); // 2s, 4s, 8s...
tokio::time::sleep(delay).await;
return self.execute(ctx).await; // retry entire pipeline
}
Err(e) => return PipelineResult::Error(ctx.id, e),
}
}
PipelineResult::Success(ctx.clone())
}
}| Beneficio | Explicación |
|---|---|
| Aislamiento | Cada stage es independiente. Si falla el extractor, el downloader no se entera |
| Testeable | Puedes testear cada stage por separado con mocks |
| Extensible | Quieres añadir un stage de "desencriptar"? Solo agregas un Stage |
| Observable | Cada stage emite eventos → progreso detallado en terminal/GUI |
| Recuperable | Sabes exactamente en qué stage falló, puedes reintentar desde ahí |
Cada descarga tiene un ciclo de vida con estados y transiciones explícitas.
┌─────────────────────────────────────────┐
│ │
▼ │
┌─────────┐ │
│ QUEUED │ │
└────┬────┘ │
│ │
┌────▼────┐ │
┌─────│RESOLVING│─────┐ │
│ └─────────┘ │ │
│ │ │
┌────▼───┐ ┌──────▼─────┐ │
│EXTRACT │ │DIRECT_DOWN │ │
└────┬───┘ └──────┬─────┘ │
│ │ │
│ ┌─────▼──────┐ │
│ │DOWNLOADING │ │
│ └─────┬──────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ │ │ │ │
│ ┌────▼───┐ ┌────▼────┐ ┌───▼────┐ │
│ │ PAUSED │ │VERIFYING│ │ ERROR │──► RETRY ───┘
│ └────┬───┘ └────┬────┘ └────────┘
│ │ │
│ ┌────▼──────────▼────┐
│ │ EXTRACTING │
│ └────┬───────────────┘
│ │
│ ┌────▼────┐
└────►│COMPLETED│
└─────────┘
/// Estados de una descarga
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum DownloadState {
// ─── Espera ───
Queued,
Waiting { position: usize },
// ─── Resolución ───
Resolving, // HEAD request, detectar tipo
ExtractingLink, // Page analyzer / plugins
// ─── Descarga ───
DirectDownload { url: String },
Downloading {
progress: ProgressInfo,
segments_completed: u32,
segments_total: u32,
},
Paused,
Verifying { checksum: Option<String> },
// ─── Post-procesamiento ───
ExtractingArchive { file: String },
// ─── Final ───
Completed(DownloadResult),
Error(DownloadError),
Retrying { attempt: u8, next_retry_at: Instant },
Cancelled,
}
/// Transiciones válidas definidas en la máquina de estados
impl DownloadState {
/// Retorna los estados SIGUIENTES válidos desde este estado
pub fn valid_transitions(&self) -> Vec<DownloadState> {
match self {
Queued => vec![Resolving, Cancelled],
Waiting { .. } => vec![Resolving, Cancelled],
Resolving => vec![
ExtractingLink,
DirectDownload { url: String::new() },
Error(DownloadError::unknown()),
Cancelled,
],
ExtractingLink => vec![
DirectDownload { url: String::new() },
Error(DownloadError::unknown()),
Cancelled,
],
DirectDownload { .. } => vec![
Downloading {
progress: ProgressInfo::default(),
segments_completed: 0,
segments_total: 0,
},
Error(DownloadError::unknown()),
Cancelled,
],
Downloading { .. } => vec![
Paused,
Verifying { checksum: None },
Error(DownloadError::unknown()),
Cancelled,
],
Paused => vec![
Downloading {
progress: ProgressInfo::default(),
segments_completed: 0,
segments_total: 0,
},
Cancelled,
],
Verifying { .. } => vec![
ExtractingArchive { file: String::new() },
Completed(DownloadResult::default()),
Error(DownloadError::unknown()),
Cancelled,
],
ExtractingArchive { .. } => vec![
Completed(DownloadResult::default()),
Error(DownloadError::unknown()),
Cancelled,
],
Error(e) => {
let mut next = vec![Cancelled];
if e.is_retryable() {
next.push(Retrying {
attempt: 0,
next_retry_at: Instant::now(),
});
}
next
}
Retrying { attempt, .. } => {
if *attempt < MAX_RETRIES {
vec![Resolving, Cancelled]
} else {
vec![Error(DownloadError::max_retries()), Cancelled]
}
}
Completed(_) | Cancelled => vec![], // terminal
}
}
/// Verifica si una transición es válida
pub fn can_transition_to(&self, next: &DownloadState) -> bool {
self.valid_transitions()
.iter()
.any(|t| std::mem::discriminant(t) == std::mem::discriminant(next))
}
}| Razón | Problema que resuelve |
|---|---|
| Sin estados inválidos | No puedes pasar de QUEUED a COMPLETED sin descargar |
| Recuperación | Sabes exactamente dónde falló y qué reintentar |
| Persistencia | Puedes guardar/restaurar el estado (resume entre sesiones) |
| UI reactiva | La GUI/Terminal escucha cambios de estado y se actualiza |
| Testing | Cada transición es testeable |
Los extractores de sitios específicos se registran en un registry que los resuelve automáticamente por URL.
/// Cualquier extractor de sitios implementa esto
#[async_trait]
pub trait SiteExtractor: Send + Sync {
fn id(&self) -> &'static str;
fn name(&self) -> &'static str;
fn priority(&self) -> u8; // más alto = primero en probar
/// ¿Este extractor puede manejar esta URL?
fn can_handle(&self, url: &Url) -> bool;
/// Extrae links de descarga de la página
async fn extract(&self, url: &Url, ctx: &mut DownloadContext) -> Result<Vec<DetectedLink>, ExtractError>;
}
/// Registry de extractores
pub struct ExtractorRegistry {
extractors: Vec<Box<dyn SiteExtractor>>,
}
impl ExtractorRegistry {
pub fn default() -> Self {
let mut reg = Self { extractors: vec![] };
// Ordenado por prioridad:
// 1. Plugins específicos (alta prioridad)
reg.register(MediaFireExtractor::new());
reg.register(YouTubeExtractor::new()); // vía yt-dlp
reg.register(MegaExtractor::new());
reg.register(GoogleDriveExtractor::new());
// 2. Detectores de formato (media prioridad)
reg.register(HlsExtractor::new()); // .m3u8
reg.register(DashExtractor::new()); // .mpd
// 3. Page Analyzer genérico (baja prioridad, fallback)
reg.register(GenericPageAnalyzer::new());
reg
}
/// Encuentra TODOS los extractores que pueden manejar esta URL
pub fn find_for_url(&self, url: &Url) -> Vec<&dyn SiteExtractor> {
let mut matched: Vec<&dyn SiteExtractor> = self.extractors
.iter()
.filter(|e| e.can_handle(url))
.map(|e| e.as_ref())
.collect();
// Ordenar por prioridad descendente
matched.sort_by(|a, b| b.priority().cmp(&a.priority()));
matched
}
}
/// Ejemplo: Extractor de MediaFire
pub struct MediaFireExtractor;
#[async_trait]
impl SiteExtractor for MediaFireExtractor {
fn id(&self) -> &'static str { "mediafire" }
fn name(&self) -> &'static str { "MediaFire" }
fn priority(&self) -> u8 { 100 }
fn can_handle(&self, url: &Url) -> bool {
url.host_str()
.map(|h| h.contains("mediafire.com"))
.unwrap_or(false)
}
async fn extract(&self, url: &Url, ctx: &mut DownloadContext) -> Result<Vec<DetectedLink>, ExtractError> {
let html = reqwest::get(url.as_str())
.await
.map_err(|e| ExtractError::network(e))?
.text()
.await
.map_err(|e| ExtractError::network(e))?;
let doc = scraper::Html::parse_document(&html);
// Pattern 1: #downloadButton
let selector = scraper::Selector::parse("#downloadButton").unwrap();
if let Some(btn) = doc.select(&selector).next() {
if let Some(href) = btn.value().attr("href") {
return Ok(vec![DetectedLink::direct(href)]);
}
}
// Pattern 2: download*.mediafire.com
let selector = scraper::Selector::parse("a[href*='download.mediafire.com']").unwrap();
for link in doc.select(&selector) {
if let Some(href) = link.value().attr("href") {
return Ok(vec![DetectedLink::direct(href)]);
}
}
Err(ExtractError::not_found("No download button found on MediaFire page"))
}
}| Prioridad | Plugin | Detecta |
|---|---|---|
| 100 | MediaFire | mediafire.com/file/... |
| 95 | YouTube | youtube.com, youtu.be, vimeo, tiktok, twitch, twitter... |
| 90 | Mega | mega.nz/file/... |
| 85 | Google Drive | drive.google.com/file/... |
| 50 | HLS | .m3u8 en URL o content-type |
| 45 | DASH | .mpd en URL o content-type |
| 10 | Page Analyzer | Cualquier página HTML (fallback genérico) |
El pipeline emite eventos. El CLI y la GUI son observers.
/// Tipos de eventos que emite el pipeline
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event")]
pub enum DownloadEvent {
/// Cambio de estado (QUEUED → RESOLVING → ...)
StateChanged {
download_id: Uuid,
old_state: DownloadState,
new_state: DownloadState,
},
/// Progreso de descarga (se emite ~cada 500ms)
Progress {
download_id: Uuid,
bytes_downloaded: u64,
total_bytes: Option<u64>,
speed_bytes_per_sec: f64,
eta_secs: Option<f64>,
percent: Option<f64>,
active_segments: u32,
},
/// Stage del pipeline completado
StageCompleted {
download_id: Uuid,
stage: String,
duration_ms: u64,
},
/// Descarga completada
Completed {
download_id: Uuid,
result: DownloadResult,
},
/// Error recuperable (se va a reintentar)
RetryableError {
download_id: Uuid,
error: DownloadError,
attempt: u8,
next_retry_in_secs: u64,
},
/// Error fatal
FatalError {
download_id: Uuid,
error: DownloadError,
},
/// Descarga cancelada por el usuario
Cancelled {
download_id: Uuid,
partial_path: Option<PathBuf>,
downloaded_bytes: u64,
},
}
/// Canal de eventos: el pipeline produce, los observers consumen
pub type EventChannel = broadcast::Sender<DownloadEvent>;
/// El Pipeline Manager expone un canal de eventos
pub struct DownloadManager {
event_tx: broadcast::Sender<DownloadEvent>,
pipeline: Pipeline,
active_downloads: HashMap<Uuid, JoinHandle<()>>,
}
impl DownloadManager {
pub fn event_receiver(&self) -> broadcast::Receiver<DownloadEvent> {
self.event_tx.subscribe()
}
}// ─── Terminal (CLI) ───
pub struct TerminalUi {
rx: broadcast::Receiver<DownloadEvent>,
progress_bars: HashMap<Uuid, ProgressBar>,
}
impl TerminalUi {
pub async fn run(&mut self) {
while let Ok(event) = self.rx.recv().await {
match event {
DownloadEvent::Progress { download_id, percent, speed, eta_secs, .. } => {
if let Some(pb) = self.progress_bars.get_mut(&download_id) {
if let Some(pct) = percent {
pb.set_position(pct as u64);
}
pb.set_message(format!("{:.1} MB/s | ETA: {:?}s", speed / 1_000_000.0, eta_secs));
}
}
DownloadEvent::Completed { download_id, result } => {
if let Some(pb) = self.progress_bars.remove(&download_id) {
pb.finish_with_message("✅ Completado");
}
}
DownloadEvent::FatalError { download_id, error } => {
eprintln!("❌ Error: {}", error);
}
_ => {}
}
}
}
}
// ─── Tauri App (GUI) ───
#[tauri::command]
async fn start_download(app: tauri::AppHandle, url: String) -> Result<Uuid, String> {
let manager = app.state::<DownloadManager>();
let id = manager.submit(url, DownloadOptions::default()).await;
// Escuchar eventos desde la GUI
let mut rx = manager.event_receiver();
let window = app.get_webview_window("main").unwrap();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
// Enviar evento a la UI de Svelte
let _ = window.emit("download-event", &event);
}
});
Ok(id)
}
// En Svelte:
// import { listen } from '@tauri-apps/api/event';
// listen('download-event', (event) => {
// // actualizar barra de progreso
// });Los errores recuperables (timeout, 503, rate limit) se reintentan con espera exponencial.
const MAX_RETRIES: u8 = 5;
const BASE_DELAY_MS: u64 = 1000;
/// Calcula el delay para el intento N usando exponential backoff + jitter
pub fn backoff_duration(attempt: u8) -> Duration {
let base = BASE_DELAY_MS * 2u64.pow(attempt as u32); // 1s, 2s, 4s, 8s, 16s
// Añadir jitter: ±25% aleatorio
use rand::Rng;
let jitter = rand::thread_rng().gen_range(0..=base / 4);
Duration::from_millis(base + jitter)
}
/// ¿Este error se puede reintentar?
impl DownloadError {
pub fn is_retryable(&self) -> bool {
matches!(self,
Self::Timeout { .. } |
Self::RateLimited { .. } |
Self::ServerError { code: 500..=599, .. } |
Self::ConnectionReset |
Self::DnsFailure { .. }
)
}
}
// Uso en el pipeline:
if let Err(e) = stage.execute(ctx).await {
if e.is_retryable() && ctx.attempt < MAX_RETRIES {
ctx.attempt += 1;
let delay = backoff_duration(ctx.attempt);
event_tx.send(DownloadEvent::RetryableError {
download_id: ctx.id,
error: e,
attempt: ctx.attempt,
next_retry_in_secs: delay.as_secs(),
});
tokio::time::sleep(delay).await;
// Reintentar desde RESOLVING (no desde el principio del todo)
continue;
}
}| Error | Retryable? |
|---|---|
| Timeout de conexión | ✅ Sí (el servidor puede recuperarse) |
| 503 Service Unavailable | ✅ Sí |
| 429 Rate Limited | ✅ Sí (con backoff más largo) |
| Connection reset | ✅ Sí |
| DNS timeout | ✅ Sí |
| 404 Not Found | ❌ No (el archivo no existe) |
| 403 Forbidden | ❌ No (necesitas credenciales) |
| SSL certificate error | ❌ No |
| Contraseña incorrecta | ❌ No |
| Sin espacio en disco | ❌ No |
| URL inválida | ❌ No |
Referencia directa de XDM (
PieceGrabber.cs,HTTPDownloaderBase.cs). XDM no divide el archivo en N partes iguales. Empieza con 1 pieza y la divide dinámicamente conforme los workers terminan. Esto redistribuye trabajo automáticamente si un servidor es lento en ciertos rangos.
─── Estático (DarkDM spec original) ──────────────────────────────
Inicio: [──────────────────────────────────────────────────────]
T1(0-25%) T2(25-50%) T3(50-75%) T4(75-100%)
Mitad: T1 ✅ T2 descarga lento... T3 ✅ T4 ✅
Fin: T1 ✅ T2 sigue... ← pierde tiempo, los demás esperan
─── Dinámico (algoritmo XDM / DarkDM futuro) ─────────────────────
Inicio: [══════════════════════════════════════════════════════]
T1 (100% del archivo)
25%: [══════]T1✅ [══════════════════════════════]T2 (split)
50%: [══════]T1✅ [══════════]T2✅ [════════════]T3 (split)
75%: T1✅ T2✅ [══════]T3✅ [══════]T4 (split)
Fin: T1✅ T2✅ T3✅ T4✅ ← todos terminan casi juntos
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PieceState {
NotStarted,
Downloading,
Finished,
Failed,
}
#[derive(Debug)]
pub struct Piece {
pub id: PieceId,
pub offset: u64, // byte de inicio en el archivo completo
pub length: u64, // bytes asignados a esta pieza (-1 si desconocido)
pub downloaded: AtomicU64, // bytes descargados hasta ahora (hot path)
pub state: Mutex<PieceState>,
pub stream_type: StreamType, // Primary | Secondary (para dual-source)
}
impl Piece {
/// Bytes que faltan descargar
pub fn remaining(&self) -> u64 {
self.length.saturating_sub(self.downloaded.load(Ordering::Relaxed))
}
/// Byte final de esta pieza en el archivo total
pub fn end_offset(&self) -> u64 {
self.offset + self.length - 1
}
/// ¿Esta pieza puede dividirse? (mínimo 256 KB restantes)
pub fn is_splittable(&self) -> bool {
self.remaining() > 256 * 1024
}
}pub struct PieceManager {
pieces: HashMap<PieceId, Piece>,
max_active: usize, // máximo de workers activos simultáneos (default: 8)
active: HashSet<PieceId>,
}
impl PieceManager {
/// Llamado cuando un worker termina su pieza o al probar el servidor.
/// Busca la pieza más grande y la divide — el nuevo fragmento
/// se asigna a un nuevo worker.
pub fn try_create_piece(&mut self) -> Option<PieceId> {
if self.active.len() >= self.max_active {
return None;
}
// 1. Reintentar piezas fallidas sin worker activo
if let Some(id) = self.retry_failed() {
self.active.insert(id);
return Some(id);
}
// 2. Encontrar la pieza con más bytes restantes (la más grande)
let split_target = self.pieces.iter()
.filter(|(id, p)| {
self.active.contains(id) // debe estar siendo descargada
&& p.is_splittable() // mínimo 256KB restantes
})
.max_by_key(|(_, p)| p.remaining())
.map(|(id, _)| *id)?;
// 3. Dividir la pieza por la mitad
let new_id = self.split_piece(split_target)?;
self.active.insert(new_id);
Some(new_id)
}
fn split_piece(&mut self, id: PieceId) -> Option<PieceId> {
let piece = self.pieces.get_mut(&id)?;
let remaining = piece.remaining();
if remaining < 256 * 1024 { return None; }
let new_length = remaining / 2;
let new_offset = piece.offset + piece.length - new_length;
// Acortar la pieza original (el final lo toma la nueva pieza)
piece.length -= new_length;
// Crear nueva pieza con la segunda mitad
let new_id = PieceId::new_v4();
self.pieces.insert(new_id, Piece {
id: new_id,
offset: new_offset,
length: new_length,
downloaded: AtomicU64::new(0),
state: Mutex::new(PieceState::NotStarted),
stream_type: StreamType::Primary,
});
Some(new_id)
}
fn retry_failed(&mut self) -> Option<PieceId> {
self.pieces.iter()
.filter(|(id, p)| {
!self.active.contains(id)
&& *p.state.lock().unwrap() == PieceState::Failed
})
.map(|(id, _)| *id)
.next()
}
}pub struct PieceWorker {
piece_id: PieceId,
callback: Arc<dyn PieceCallback>,
client: reqwest::Client,
max_retries: u8,
}
impl PieceWorker {
pub async fn run(&self) -> Result<(), PieceError> {
let mut retries = 0u8;
loop {
match self.download_piece().await {
Ok(()) => {
self.callback.piece_finished(self.piece_id);
return Ok(());
}
Err(e) if e.is_retryable() && retries < self.max_retries => {
retries += 1;
let delay = Duration::from_secs(2u64.pow(retries as u32)); // 2s, 4s, 8s
tokio::time::sleep(delay).await;
continue;
}
Err(e) => {
self.callback.piece_failed(self.piece_id, e.into());
return Err(e);
}
}
}
}
async fn download_piece(&self) -> Result<(), PieceError> {
let piece = self.callback.get_piece(self.piece_id);
let is_first = self.callback.is_first_request(piece.stream_type);
// Construir Range header
let range = if is_first {
"bytes=0-".to_string() // probe: open-ended
} else {
let start = piece.offset + piece.downloaded.load(Ordering::Relaxed);
let end = piece.offset + piece.length - 1;
format!("bytes={}-{}", start, end)
};
let headers = self.callback.get_headers(self.piece_id);
let response = self.client
.get(&piece.url)
.header("Range", &range)
.header("Accept-Encoding", "identity") // ← CRÍTICO: no comprimir
.headers(headers.unwrap_or_default())
.send()
.await
.map_err(PieceError::Network)?;
// En el primer request: detectar Text Redirect
if is_first {
if let Some(ct) = response.headers().get("content-type") {
if ct == "text/plain" {
// El body es la URL real (algunos CDNs hacen esto)
let new_url = response.text().await?.trim().to_string();
return Err(PieceError::TextRedirect(new_url));
}
}
// Extraer ProbeResult y notificar al orchestrator
let probe = ProbeResult::from_response(&response);
self.callback.piece_connected(self.piece_id, Some(probe));
} else {
// En requests posteriores: exigir 206 Partial Content
if response.status() != 206 {
return Err(PieceError::RangeNotSupported);
}
self.callback.piece_connected(self.piece_id, None);
}
// Stream body → archivo temporal
let temp_path = self.callback.piece_file(self.piece_id);
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.open(&temp_path)
.await?;
// Seek al offset ya descargado (resume de pieza parcial)
let already = piece.downloaded.load(Ordering::Relaxed);
if already > 0 {
file.seek(SeekFrom::Start(already)).await?;
}
const BUF: usize = 32 * 1024; // 32 KB buffer
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let bytes = chunk.map_err(PieceError::Network)?;
file.write_all(&bytes).await?;
piece.downloaded.fetch_add(bytes.len() as u64, Ordering::Relaxed);
self.callback.update_bytes(self.piece_id, bytes.len() as u64);
// Intentar adoptar pieza adyacente (reutilizar conexión TCP)
let max_range = piece.offset + piece.length;
if self.callback.continue_adjacent(self.piece_id, max_range) {
// El orchestrator nos dio más trabajo dentro de esta conexión
continue;
}
}
Ok(())
}
}El worker nunca conoce al orchestrator — solo habla con este trait. Permite testing con mocks:
#[async_trait]
pub trait PieceCallback: Send + Sync {
/// ¿Es el primer request para este tipo de stream?
fn is_first_request(&self, stream: StreamType) -> bool;
/// ¿El archivo cambió en el servidor? (Content-Range length diferente)
fn file_changed_on_server(&self, id: PieceId, new_size: u64) -> bool;
/// Obtener datos de la pieza
fn get_piece(&self, id: PieceId) -> Arc<Piece>;
/// Obtener headers HTTP para esta pieza (User-Agent, Cookie, Referer)
fn get_headers(&self, id: PieceId) -> Option<HeaderMap>;
/// El worker se conectó — ProbeResult es Some() solo en el primer request
fn piece_connected(&self, id: PieceId, probe: Option<ProbeResult>);
/// Ruta del archivo temporal para escribir esta pieza
fn piece_file(&self, id: PieceId) -> PathBuf;
/// Worker descargó N bytes — actualizar contadores globales
fn update_bytes(&self, id: PieceId, bytes: u64);
/// ¿Puede el worker adoptar la pieza adyacente sin cerrar la conexión?
fn continue_adjacent(&self, id: PieceId, current_range_end: u64) -> bool;
/// Pieza completada
fn piece_finished(&self, id: PieceId);
/// Pieza falló
fn piece_failed(&self, id: PieceId, error: ErrorCode);
/// Aplicar speed limit si hay uno configurado (bloquea el worker)
fn throttle_if_needed(&self);
}Cuando un worker termina su pieza pero el servidor ya envió datos más allá de ese rango, puede adoptar la siguiente pieza sin cerrar la conexión TCP:
// En el orchestrator (implementa PieceCallback)
fn continue_adjacent(&self, id: PieceId, current_range_end: u64) -> bool {
let pieces = self.pieces.read().unwrap();
let active = self.active.read().unwrap();
// Buscar pieza que empiece exactamente donde termina la actual
let adjacent = pieces.iter().find(|(adj_id, adj_piece)| {
adj_piece.offset == current_range_end + 1 // contigua
&& adj_piece.downloaded.load(Ordering::Relaxed) == 0 // no iniciada
&& !active.contains(adj_id) // sin worker activo
&& adj_piece.stream_type == pieces[&id].stream_type
});
if let Some((adj_id, _)) = adjacent {
// Adoptar: el worker continúa leyendo en la misma conexión HTTP
// La pieza adyacente ahora es "propiedad" de este worker
self.active.write().unwrap().insert(*adj_id);
// Liberar un slot → intentar crear otra pieza con splitting
drop(pieces);
drop(active);
self.try_create_piece();
true // el worker continúa sin cerrar la conexión
} else {
false // el worker cierra la conexión y termina
}
}/// Información obtenida del primer request a la URL
#[derive(Debug, Clone)]
pub struct ProbeResult {
/// Tamaño total del archivo (de Content-Length o Content-Range)
pub resource_size: Option<u64>,
/// ¿El servidor soporta Range requests? (respuesta 206 = sí, 200 = no)
pub resumable: bool,
/// URL final después de todos los redirects
pub final_uri: String,
/// Nombre de archivo sugerido (de Content-Disposition: attachment; filename=...)
pub filename: Option<String>,
/// MIME type del contenido
pub content_type: Option<String>,
/// Última modificación (para detectar si el archivo cambió entre sesiones)
pub last_modified: Option<SystemTime>,
}
impl ProbeResult {
pub fn from_response(response: &reqwest::Response) -> Self {
let headers = response.headers();
// Tamaño desde Content-Length o Content-Range
let resource_size = headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.or_else(|| {
// Content-Range: bytes 0-1023/1234567
headers.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split('/').last())
.and_then(|v| v.parse::<u64>().ok())
});
let resumable = response.status() == 206;
// Content-Disposition: attachment; filename="archivo.mp4"
let filename = headers
.get("content-disposition")
.and_then(|v| v.to_str().ok())
.and_then(|v| {
v.split(';')
.find(|s| s.trim().starts_with("filename="))
.map(|s| s.trim().trim_start_matches("filename=").trim_matches('"').to_string())
});
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|v| v.split(';').next().unwrap_or(v).trim().to_string());
let last_modified = headers
.get("last-modified")
.and_then(|v| v.to_str().ok())
.and_then(|v| httpdate::parse_http_date(v).ok());
ProbeResult {
resource_size,
resumable,
final_uri: response.url().to_string(),
filename,
content_type,
last_modified,
}
}
}1. HEAD request → ProbeResult
├── ¿resumable? ¿tamaño conocido?
├── ¿Content-Length < 10MB? → single thread
└── ¿disk space suficiente? → error temprano
2. Crear Piece(offset=0, length=total_size)
└── Lanzar PieceWorker(piece_0)
3. piece_connected() recibido con ProbeResult:
└── try_create_piece() → split piece_0 en piece_0 y piece_1
└── Lanzar PieceWorker(piece_1)
└── try_create_piece() → split más si hay slots libres
4. Cada vez que un worker termina:
└── piece_finished(id)
└── try_create_piece() → split la pieza más grande restante
└── Lanzar nuevo PieceWorker(new_piece)
5. Progreso (cada 500ms):
└── Sumar downloaded de todos los Pieces
└── Calcular speed = Δbytes / Δtime
└── ETA = (total - downloaded) / speed
└── Emitir DownloadEvent::Progress
6. Estado persistido (cada 2s via TransactedIO):
└── Serializar todos los Pieces a {id}.pieces
7. Todos los workers terminan:
└── Ordenar piezas por offset
└── Concatenar archivos temporales → archivo final
└── Emitir DownloadEvent::Completed
| Tamaño archivo | Workers recomendados | Ganancia vs 1 worker | Observaciones |
|---|---|---|---|
| < 10 MB | 1 | Ninguna | Overhead no vale |
| 10-100 MB | 2 | ~1.5x | Split 1 vez |
| 100 MB - 1 GB | 4 | ~2.5x | Split 3 veces |
| 1 GB - 10 GB | 8 | ~4x | Split 7 veces |
| > 10 GB | 8-16 | ~5x | Limitado por ancho de banda |
Referencia directa de XDM (
TransactedIO.cs). Si el proceso muere mientras escribe el estado de las piezas, el archivo anterior queda intacto. Usarename(2)que es atómico en Linux.
Archivos de estado:
{id}.pieces.1 ← estado actual (el válido)
{id}.pieces.2 ← backup del estado anterior
{id}.pieces.tmp ← escritura en curso (inválido si existe solo)
Rotación al escribir:
1. Escribir nuevo estado en .tmp (con marcador END al final)
2. Si .1 existe: rename .1 → .2 (backup atómico)
3. rename .tmp → .1 (atómico: ahora .1 es el válido)
Al leer (resume después de crash):
1. Intentar leer .1, validar marcador END
2. Si .1 inválido: intentar leer .2
3. Si ambos inválidos: descarga desde cero
const END_MARKER: &[u8] = b"END.";
pub struct TransactedIO;
impl TransactedIO {
pub fn write(path: &Path, data: &[u8]) -> io::Result<()> {
let p1 = path.with_extension("1");
let p2 = path.with_extension("2");
let tmp = path.with_extension("tmp");
// Escribir a .tmp con marcador END
let mut f = fs::File::create(&tmp)?;
f.write_all(data)?;
f.write_all(END_MARKER)?;
f.flush()?;
drop(f);
// Rotar: .1 → .2 (backup), .tmp → .1 (nuevo válido)
if p1.exists() {
fs::rename(&p1, &p2)?; // atómico en Linux
}
fs::rename(&tmp, &p1)?; // atómico en Linux
Ok(())
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
let p1 = path.with_extension("1");
let p2 = path.with_extension("2");
for candidate in [&p1, &p2] {
if let Ok(data) = fs::read(candidate) {
if data.ends_with(END_MARKER) {
// Válido: quitar el marcador y retornar
return Ok(data[..data.len() - END_MARKER.len()].to_vec());
}
}
}
Err(io::Error::new(io::ErrorKind::NotFound, "No valid state file found"))
}
}
/// Serialización del estado de piezas para persistencia
fn serialize_pieces(pieces: &HashMap<PieceId, Piece>) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&(pieces.len() as u32).to_le_bytes());
for (id, piece) in pieces {
buf.extend_from_slice(id.as_bytes());
buf.extend_from_slice(&piece.offset.to_le_bytes());
buf.extend_from_slice(&piece.length.to_le_bytes());
buf.extend_from_slice(&piece.downloaded.load(Ordering::Relaxed).to_le_bytes());
buf.push(*piece.state.lock().unwrap() as u8);
buf.push(piece.stream_type as u8);
}
buf
}El resume ocurre en dos niveles:
Nivel 1 — Resume entre sesiones (el proceso fue cerrado):
pub async fn resume_or_start(
url: &str,
output: &Path,
state_path: &Path,
) -> DownloadTask {
// 1. ¿Existe estado guardado?
if let Ok(data) = TransactedIO::read(state_path) {
if let Ok(pieces) = deserialize_pieces(&data) {
// Verificar que el archivo no cambió en el servidor
let probe = probe_url(url).await?;
let saved_size = pieces.values().map(|p| p.length).sum::<u64>();
if probe.resource_size == Some(saved_size)
&& probe.resumable {
// Reanudar desde donde se quedó
return DownloadTask::resume(pieces, probe);
}
}
}
// 2. No hay estado válido → empezar desde cero
DownloadTask::start_fresh(url, output)
}Nivel 2 — Resume dentro de una pieza (conexión TCP cortada):
// En PieceWorker.download_piece():
// Seek al offset ya descargado antes de escribir
let already = piece.downloaded.load(Ordering::Relaxed);
if already > 0 {
file.seek(SeekFrom::Start(already)).await?;
// El Range header incluye el offset:
// Range: bytes={offset + already}-{offset + length - 1}
}Aprendido de XDM: Si el servidor comprime la respuesta (
gzip,br, etc.), elContent-Lengthreportado corresponde al tamaño comprimido, no al real. Esto rompe los cálculos de Range porque:
- El archivo real es más grande que
Content-Length- Los byte ranges no corresponden a offsets reales en el archivo
// SIEMPRE en todos los requests del download engine:
client
.get(url)
.header("Accept-Encoding", "identity") // ← forzar sin compresión
.header("Range", range)
.send()
.await?;Regla: Accept-Encoding: identity va en todos los requests del engine (probe, pieces, segments). No hay excepción.
Algunos CDNs no responden con el archivo directamente — responden con Content-Type: text/plain y el body es la URL real:
// En PieceWorker, primer request únicamente:
if is_first_request {
let ct = response.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if ct.starts_with("text/plain") {
let new_url = response.text().await?.trim().to_string();
if new_url.starts_with("http") {
return Err(PieceError::TextRedirect(new_url));
}
}
}
// El orchestrator captura TextRedirect y reintenta con la nueva URL:
match worker.run().await {
Err(PieceError::TextRedirect(new_url)) => {
// Actualizar URL y reintentar
self.update_url(&piece_id, new_url);
self.retry_piece(piece_id);
}
_ => {}
}Si ya se descargaron bytes y luego el servidor responde con 401/403, la sesión expiró (cookies caducadas). Es un error diferente a un 403 desde el principio:
impl PieceWorker {
fn classify_error(&self, status: StatusCode, already_downloaded: u64) -> PieceError {
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
if already_downloaded > 0 {
// Ya teníamos sesión, se expiró
PieceError::SessionExpired
} else {
// Nunca tuvimos acceso
PieceError::AccessDenied
}
}
StatusCode::NOT_FOUND => PieceError::NotFound,
s if s.is_server_error() => PieceError::ServerError(s.as_u16()),
s => PieceError::UnexpectedStatus(s.as_u16()),
}
}
}
// En el CLI, SessionExpired muestra un mensaje específico:
// ❌ La sesión expiró (las cookies se invalidaron durante la descarga)
// → Pausa la descarga y vuelve a iniciar sesión en el sitio
// → Luego: darkdm reanudar <url> --cookie "nueva_cookie=valor"Antes de iniciar cualquier descarga grande, verificar espacio disponible tanto en el directorio temporal como en el destino final:
pub fn check_disk_space(
temp_dir: &Path,
output_dir: &Path,
needed_bytes: u64,
) -> Result<(), DownloadError> {
// En Linux: statvfs syscall
let temp_free = available_space(temp_dir)?;
let output_free = available_space(output_dir)?;
// Para archivos multi-pieza necesitamos espacio en TEMP
// para las piezas + espacio en OUTPUT para el archivo final
// Total = 2x el tamaño (piezas temporales + archivo final)
let needed_total = needed_bytes * 2;
if temp_free < needed_bytes {
return Err(DownloadError::InsufficientDiskSpace {
location: temp_dir.to_path_buf(),
needed: needed_bytes,
available: temp_free,
});
}
if output_free < needed_bytes {
return Err(DownloadError::InsufficientDiskSpace {
location: output_dir.to_path_buf(),
needed: needed_bytes,
available: output_free,
});
}
Ok(())
}
// Crate disponible: `fs2` o `statvfs`
// fs2::available_space(path) -> u64Si el usuario configura un límite de velocidad (ej: 1 MB/s para no saturar la red), el limiter bloquea cada worker cuando va demasiado rápido:
pub struct SpeedLimiter {
limit_bytes_per_sec: Option<u64>, // None = sin límite
last_check: Instant,
bytes_since_check: u64,
cancel: Arc<Notify>, // despertable en Stop()
}
impl SpeedLimiter {
pub async fn throttle(&mut self, bytes_written: u64) {
let Some(limit) = self.limit_bytes_per_sec else { return };
self.bytes_since_check += bytes_written;
let elapsed = self.last_check.elapsed().as_millis() as u64;
let max_bytes_per_ms = limit / 1000;
let expected_ms = self.bytes_since_check / max_bytes_per_ms;
if elapsed < expected_ms {
let sleep_ms = expected_ms - elapsed;
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(sleep_ms)) => {}
_ = self.cancel.notified() => {} // se despierta en Stop()
}
}
if self.last_check.elapsed() > Duration::from_millis(1000) {
self.last_check = Instant::now();
self.bytes_since_check = 0;
}
}
}pub fn resolve_output_path(dir: &Path, filename: &str) -> PathBuf {
let path = dir.join(filename);
if !path.exists() {
return path;
}
let stem = Path::new(filename)
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let ext = Path::new(filename)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
for i in 1..=999 {
let candidate = dir.join(format!("{} ({}){}", stem, i, ext));
if !candidate.exists() {
return candidate;
}
}
// Fallback con timestamp
dir.join(format!("{} ({}){}", stem, chrono::Utc::now().timestamp(), ext))
}Algunos playlists HLS almacenan múltiples segmentos en un solo archivo, accedidos por byte ranges:
#EXTM3U
#EXTINF:10.0,
#EXT-X-BYTERANGE:1048576@0
video.ts
#EXTINF:10.0,
#EXT-X-BYTERANGE:1048576@1048576
video.ts
El parser HLS debe acumular el offset y emitir un Chunk con Range header:
#[derive(Debug)]
pub struct HlsChunk {
pub url: String,
pub byte_range: Option<ByteRange>, // Some(start, len) si es byterange
pub duration: f64,
pub sequence: u32,
}
#[derive(Debug)]
pub struct ByteRange {
pub offset: u64,
pub length: u64,
}
// El downloader de chunks usa Range si byte_range es Some:
if let Some(range) = &chunk.byte_range {
request = request.header(
"Range",
format!("bytes={}-{}", range.offset, range.offset + range.length - 1)
);
}Múltiples descargas se encolan y ejecutan con límite de concurrentes.
pub struct QueueManager {
event_tx: broadcast::Sender<DownloadEvent>,
max_concurrent: usize, // default: 3
queue: VecDeque<QueuedDownload>,
active: HashMap<Uuid, DownloadHandle>,
completed: Vec<DownloadResult>,
}
impl QueueManager {
/// Añadir una URL a la cola
pub async fn enqueue(&mut self, url: String, opts: DownloadOptions) -> Uuid {
let id = Uuid::new_v4();
self.queue.push_back(QueuedDownload { id, url, opts });
let _ = self.event_tx.send(DownloadEvent::StateChanged {
download_id: id,
old_state: DownloadState::Queued,
new_state: DownloadState::Queued,
});
self.try_process_next().await;
id
}
/// Procesar la siguiente descarga si hay espacio
async fn try_process_next(&mut self) {
while self.active.len() < self.max_concurrent {
if let Some(next) = self.queue.pop_front() {
let id = next.id;
let tx = self.event_tx.clone();
let handle = tokio::spawn(async move {
let mut pipeline = Pipeline::default();
let mut ctx = DownloadContext::new(id, next.url, next.opts);
let result = pipeline.execute(&mut ctx).await;
let _ = tx.send(DownloadEvent::Completed {
download_id: id,
result: result.into_result(),
});
});
self.active.insert(id, DownloadHandle { task: handle });
} else {
break;
}
}
}
/// Obtener estado de la cola
pub fn status(&self) -> QueueStatus {
QueueStatus {
queued: self.queue.len(),
active: self.active.len(),
completed: self.completed.len(),
max_concurrent: self.max_concurrent,
}
}
}
#[derive(Debug, Serialize)]
pub struct QueueStatus {
pub queued: usize,
pub active: usize,
pub completed: usize,
pub max_concurrent: usize,
}src-tauri/src/
├── lib.rs ← Tauri app + re-exporta downloader
├── main.rs ← Entry point Tauri
│
├── bin/
│ └── cli.rs ← darkdm CLI (clap)
│
├── downloader/ ← Engine compartido
│ ├── mod.rs ← Re-exporta todo
│ ├── pipeline.rs ← Pipeline orchestrator
│ ├── state.rs ← State machine
│ ├── context.rs ← DownloadContext
│ ├── events.rs ← Event system
│ │
│ ├── stages/ ← Pipeline stages
│ │ ├── mod.rs
│ │ ├── url_resolver.rs ← Stage 1: HEAD + detectar tipo
│ │ ├── link_extractor.rs ← Stage 2: Plugin registry
│ │ ├── download_engine.rs │
│ │ │ ├── direct.rs ← Descarga single-thread
│ │ │ ├── segmented.rs ← Multi-hilo con Range
│ │ │ └── resume.rs ← Resume handler
│ │ ├── hls.rs ← HLS download
│ │ ├── dash.rs ← DASH download
│ │ └── post_processor.rs │
│ │ ├── extract.rs ← Archive extraction
│ │ └── organizer.rs ← Video/organize
│ │
│ ├── plugins/ ← Site-specific extractors
│ │ ├── mod.rs ← Registry + trait
│ │ ├── mediafire.rs
│ │ ├── youtube.rs ← yt-dlp wrapper
│ │ ├── mega.rs
│ │ ├── googledrive.rs
│ │ ├── hls_detector.rs
│ │ ├── dash_detector.rs
│ │ └── generic_page.rs ← Fallback genérico
│ │
│ ├── queue.rs ← Queue manager
│ ├── retry.rs ← Exponential backoff
│ └── progress.rs ← Progress types
│
└── ...
# ─── Modo IDM: URL directa ───
darkdm descargar "https://cdn.ejemplo.com/video.mp4"
darkdm descargar "https://cdn.ejemplo.com/video.mp4" ~/Videos
darkdm descargar "https://cdn.ejemplo.com/video.mp4" --referer "https://sitio.com" --user-agent "Mozilla/5.0"
darkdm descargar "https://cdn.ejemplo.com/video.mp4" --cookie "session=abc123"
darkdm descargar "https://cdn.ejemplo.com/video.mp4" --threads 8
# ─── Modo página web (extracción automática) ───
darkdm descargar "https://mediafire.com/file/XXXX/archivo.rar/file"
darkdm descargar "https://mediafire.com/file/XXXX/archivo.rar/file" --password "mipass"
# ─── HLS / DASH ───
darkdm descargar "https://cdn.ejemplo.com/stream.m3u8"
darkdm descargar "https://cdn.ejemplo.com/manifest.mpd"
# ─── Solo info ───
darkdm info "https://cdn.ejemplo.com/video.mp4"
# → Nombre: video.mp4 | Tamaño: 1.2 GB | Tipo: video/mp4 | Resumen: yes
# ─── Output para scripting ───
darkdm descargar "https://..." --jsonMuestra información del archivo sin descargar:
- Nombre del archivo
- Tamaño
- Content-Type
- ¿Soporta Range? (para resume)
- Si es página web: qué enlaces detecta
| Flag | Descripción | Default |
|---|---|---|
--dir <path> |
Directorio de descarga | ~/Descargas/DarkDM |
--password <p> |
Contraseña para archives | — |
--referer <url> |
Header Referer | — |
--user-agent <ua> |
Header User-Agent | Chrome 124 |
--cookie <str> |
Header Cookie | — |
--header <k:v> |
Header custom (multi) | — |
--threads <n> |
Número de hilos | 4 |
--resume |
Reanudar si hay archivo parcial | true |
--keep-archive |
Conservar .rar/.zip tras extraer | true |
--video-only |
Solo extraer videos del archive | false |
--timeout <secs> |
Timeout por conexión | 30 |
--max-time <secs> |
Timeout total | 3600 |
--get-link |
Solo mostrar link directo | false |
--quiet |
Sin output a terminal | false |
--json |
Output en JSON | false |
--interactive |
Elegir manualmente entre múltiples links | false |
--show-all |
Mostrar todos los links encontrados (sin filtrar) | false |
--min-size <n> |
Tamaño mínimo del archivo (ej: 10MB, 1GB) | 1MB |
--max-size <n> |
Tamaño máximo del archivo | — |
--ext <exts> |
Extensiones permitidas (ej: mp4,mkv,avi) | — |
--quality <res> |
Calidad preferida (ej: 1080p, 720p, best) | best |
--audio-only |
Solo audio | false |
--video-only |
Solo video del archive | false |
| Aspecto | Bash actual | Rust nativo |
|---|---|---|
| Velocidad | Lento (curl 1 hilo) | Multi-hilo con Range (3-5x más rápido) |
| Resume | -C - frágil |
Range headers + verificación |
| HTML parsing | grep/regex frágil | scraper crate (CSS selectors) |
| Dependencias | curl, unrar, 7z, ffmpeg | Solo runtime de Rust (estático) |
| Progreso | Barra de curl, no capturable | Callbacks → terminal + Tauri |
| Errores | Códigos de salida genéricos | Tipos de error específicos |
| Multi-hilo | No | Sí, segmentación con Range |
| Extensible | Scripts separados | Plugin trait + registro |
| Testing | Casi imposible | Tests unitarios + integración |
| Mantenimiento | Parches sobre parches | Código estructurado |
| Aspecto | Impacto |
|---|---|
| Compilación | cargo build tarda ~2-3 min (vs bash instantáneo) |
| Tamaño binario | ~10-15 MB (vs bash ~5 KB) |
| Curva de aprendizaje | Rust es complejo (vs bash que cualquiera modifica) |
| Prototipado | Bash es más rápido para probar ideas |
| Dependencias crates | Necesitas internet para cargo build |
| RAR nativo | No hay buen crate Rust para RAR → sigue dependiendo de unrar CLI |
| yt-dlp | Sigue siendo dependencia externa para YouTube |
| Situación | Recomendación |
|---|---|
| Quieres hacer cambios rápidos | Bash |
| Quieres velocidad y confiabilidad | Rust |
| Archivos pequeños (< 100MB) | Bash o Rust, da igual |
| Archivos grandes (1GB+) | Rust (multi-hilo cambia todo) |
| Producción/estable | Rust |
| Scripting personal | Da igual, el CLI funciona igual |
darkdm descargar "https://cdn.ejemplo.com/video.mp4"| Paso | Qué pasa |
|---|---|
| 1 | HEAD request → Content-Type: video/mp4, Content-Length: 1.2GB |
| 2 | Verifica Accept-Ranges: bytes → sí, multi-hilo posible |
| 3 | Divide en 4 partes (300MB c/u) |
| 4 | Descarga en paralelo con Range headers |
| 5 | Ensambla en ~/Descargas/DarkDM/video.mp4 |
| ✅ | 3-5x más rápido que curl single-thread |
# Se cortó a los 500MB...
darkdm descargar "https://cdn.ejemplo.com/video.mp4"
# Vuelve a ejecutar
darkdm descargar "https://cdn.ejemplo.com/video.mp4"| Paso | Qué pasa |
|---|---|
| 1 | Detecta video.mp4 parcial (500MB) en destino |
| 2 | HEAD request → Content-Length: 1.2GB |
| 3 | Calcula faltante: 700MB |
| 4 | Range: bytes=500000000- |
| 5 | Descarga solo lo que falta + verifica integridad |
| ✅ | No descarga lo que ya tiene |
darkdm descargar "https://www.mediafire.com/file/XXXX/archivo.rar/file" --password "mipass"| Paso | Qué pasa |
|---|---|
| 1 | Detecta mediafire.com → plugin MediaFire |
| 2 | Fetch página HTML con reqwest |
| 3 | CSS selector #downloadButton → href |
| 4 | Extrae link directo: https://download1350.mediafire.com/.../archivo.rar |
| 5 | Descarga link directo (multi-hilo si servidor lo permite) |
| 6 | Detecta .rar → unrar x -p"mipass" |
| 7 | Busca videos → los mueve al destino |
| ✅ | Todo automático |
darkdm descargar "https://www.youtube.com/watch?v=dQw4w9WgXcQ"| Paso | Qué pasa |
|---|---|
| 1 | Detecta youtube.com → plugin YouTube |
| 2 | yt-dlp --dump-json → obtiene info del video |
| 3 | Muestra: título, duración, formatos disponibles |
| 4 | yt-dlp -f bestvideo+bestaudio --merge-output-format mp4 |
| 5 | yt-dlp descarga y mergea video+audio |
| ✅ | 1000+ sitios soportados |
darkdm descargar "https://ejemplo.com/pelicula"| Paso | Qué pasa |
|---|---|
| 1 | HEAD → Content-Type: text/html |
| 2 | Fetch HTML completo |
| 3 | page_analyzer busca: <video>, <source>, anchors .mp4, scripts |
| 4 | Filtra: ads, archivos < 1MB, duplicados |
| 5 | Encuentra 3 videos: 1080p, 720p, 480p |
| 6 | Selecciona el mejor (1080p) automáticamente |
| 7 | Descarga link directo |
| ✅ | Sin configuración específica por sitio |
darkdm descargar \
"https://mediafire.com/file/XXXX/aaa.rar/file" \
"https://mediafire.com/file/XXXX/bbb.rar/file" \
"https://youtube.com/watch?v=xxxx"| Paso | Qué pasa |
|---|---|
| 1 | Encola las 3 URLs |
| 2 | Descarga una por una |
| 3 | Muestra progreso total: "2/3 completadas" |
| ✅ | Descarga por lotes |
darkdm info "https://www.youtube.com/watch?v=dQw4w9WgXcQ"📹 Rick Astley - Never Gonna Give You Up
Canal: Rick Astley
Duración: 3:32
Publicado: 2009-10-25
Vistas: 1.5B
Formatos:
18 360p mp4 video+audio 22 MB
22 720p mp4 video+audio 45 MB
137 1080p mp4 video only 38 MB
140 m4a audio only 3 MB
darkdm info "https://mediafire.com/file/XXXX/archivo.rar/file"📦 archivo.rar (2.7 GB)
Link directo: https://download1350.mediafire.com/...
Contraseña: no detectada
Contenido probable: video (por tamaño)
darkdm descargar "ytsearch:never gonna give you up"🔍 Buscando: "never gonna give you up"
Resultado #1: Rick Astley - Never Gonna Give You Up (3:32)
Descargando...
darkdm descargar "https://..." --json | jq '.files[].path'
# "/home/dark/Descargas/DarkDM/video.mp4"
darkdm descargar "https://..." --json | notify-send "Descarga completa"darkdm descargar "https://ejemplo.com" --interactive🔍 3 videos encontrados:
1) 1080p video.mp4 2.1 GB
2) 720p video.mp4 1.2 GB
3) 480p video.mp4 700 MB
Elige (1-3): 2
⬇️ Descargando 720p...
| Categoría | Ejemplo | Mensaje |
|---|---|---|
| URL inválida | darkdm descargar "no-es-url" |
❌ URL inválida: "no-es-url". Formato esperado: https://... |
| Sin conexión | darkdm descargar "https://ejemplo.com" (sin internet) |
❌ No se pudo conectar a ejemplo.com. Verifica tu conexión a internet. |
| DNS fail | darkdm descargar "https://sitioquenoexiste123.com" |
❌ No se pudo resolver el dominio: sitioquenoexiste123.com |
| Timeout | darkdm descargar "https://server-lento.com/video.mp4" |
❌ Timeout después de 30s. --timeout para aumentarlo. |
| 404 | darkdm descargar "https://ejemplo.com/noexiste.mp4" |
❌ 404 Not Found. El archivo ya no existe en el servidor. |
| 403 | darkdm descargar "https://ejemplo.com/video.mp4" |
❌ 403 Forbidden. Necesitas --referer o --cookie. |
| SSL Error | darkdm descargar "https://sitio-con-cert-viejo.com" |
❌ Error SSL: certificado expirado. |
| Redirect loop | darkdm descargar "https://sitio.com/a" |
❌ Demasiados redirects. El servidor está en un bucle. |
| Sin espacio | Disco lleno | ❌ No hay espacio en disco. Faltan 500 MB libres. |
| Permiso denegado | darkdm descargar "..." --dir /root/ |
❌ No tienes permisos de escritura en /root/. Usa --dir ~/Descargas/ |
| Error | Causa | Solución |
|---|---|---|
No se encontró link en la página |
La página no tiene videos ni enlaces de descarga | El sitio no es compatible. Usa la extensión de Chrome. |
MediaFire: no se encontró downloadButton |
MediaFire cambió su HTML | Reportar issue. Usar --get-link con URL directa. |
YouTube requiere yt-dlp |
yt-dlp no está instalado | pip install yt-dlp o sudo pacman -S yt-dlp |
Página requiere login |
El contenido está detrás de autenticación | Usa la extensión de Chrome (tiene tus cookies de sesión) |
Video protegido por DRM |
Netflix, Disney+, etc. | No soportado. DRM no se puede descifrar. |
| Error | Causa | Solución |
|---|---|---|
RAR protegido o corrupto |
Contraseña incorrecta o archivo dañado | Verifica la contraseña con --password. Si está dañado, reintenta descarga. |
ZIP corrupto |
Descarga incompleta | Vuelve a ejecutar (resume automático). |
unrar no está instalado |
Falta dependencia | sudo pacman -S unrar o sudo apt install unrar |
Formato de archive no soportado |
.ace, .arj, etc. | Instala unar (The Unarchiver) que soporta más formatos. |
| Código | Significado |
|---|---|
0 |
✅ Descarga completada exitosamente |
1 |
❌ Error general (URL inválida, sin conexión, etc.) |
2 |
❌ Error de red (timeout, 404, 403, etc.) |
3 |
❌ Error de scraping (no se encontró link) |
4 |
❌ Error de extracción (RAR corrupto, contraseña incorrecta) |
5 |
❌ Error de disco (sin espacio, permisos) |
6 |
❌ Dependencia faltante (yt-dlp, unrar no instalado) |
130 |
🛑 Cancelado por el usuario (Ctrl+C) |
# Error de conexión
$ darkdm descargar "https://sitioquenoexiste123.com/video.mp4"
❌ No se pudo resolver el dominio: sitioquenoexiste123.com
→ Verifica que la URL sea correcta
→ Verifica tu conexión a internet
→ Si el sitio existe, prueba con --timeout 60
Salida: 1
# Error 404
$ darkdm descargar "https://mediafire.com/file/XXXX/viejo.rar/file"
❌ 404 Not Found (MediaFire)
→ El archivo fue eliminado o la URL es incorrecta
→ Verifica que el archivo sigue disponible en mediafire.com
Salida: 2
# Error de scraping
$ darkdm descargar "https://ejemplo.com"
❌ No se encontró ningún link de descarga en la página
→ El analizador revisó: <video>, <source>, anchors, scripts
→ Sugerencias:
1. Usa la extensión de Chrome (captura cualquier stream)
2. Si es MediaFire/YouTube, el plugin debería detectarlo
3. Pasa la URL directa si la tienes
Salida: 3
# Error de contraseña
$ darkdm descargar "https://mediafire.com/file/XXXX/archivo.rar/file" --password "wrong"
❌ Contraseña incorrecta para archivo.rar
→ --password "contraseña_correcta"
→ Si no sabes la contraseña, el .rar se guardó sin extraer
Salida: 4
# Dependencia faltante
$ darkdm descargar "https://youtube.com/watch?v=xxxx"
⚠️ YouTube requiere yt-dlp
→ Instálalo: pip install yt-dlp
→ O: sudo pacman -S yt-dlp
→ Luego reintenta
Salida: 6
# Cancelado por usuario
$ darkdm descargar "https://cdn.ejemplo.com/video-grande.mp4"
⬇️ Descargando... (Ctrl+C presionado)
🛑 Descarga cancelada
→ El archivo parcial queda en ~/Descargas/DarkDM/video-grande.mp4.partial
→ Reintenta y se reanudará automáticamente
Salida: 130{
"status": "error",
"code": 2,
"error": "404 Not Found",
"url": "https://ejemplo.com/noexiste.mp4",
"suggestion": "El archivo fue eliminado o la URL es incorrecta",
"partial_path": null,
"timestamp": "2026-06-24T10:30:00Z"
}{
"status": "cancelled",
"code": 130,
"error": "Cancelado por usuario",
"partial_path": "/home/dark/Descargas/DarkDM/video.mp4.partial",
"partial_bytes": 524288000,
"total_bytes": 1073741824,
"progress_percent": 48.8,
"timestamp": "2026-06-24T10:30:00Z"
}pub struct DownloadOptions {
pub url: String,
pub output_dir: PathBuf,
pub filename: Option<String>,
pub password: Option<String>,
pub referer: Option<String>,
pub user_agent: Option<String>,
pub cookies: Option<String>,
pub extra_headers: Vec<(String, String)>,
pub num_threads: u32,
pub resume: bool,
pub keep_archive: bool,
pub video_only: bool,
pub connection_timeout: u64,
pub max_time: u64,
}
pub struct ProgressInfo {
pub bytes_downloaded: u64,
pub total_bytes: Option<u64>, // None si no se sabe
pub speed: f64, // bytes/sec
pub eta: Option<Duration>,
pub phase: DownloadPhase, // Connecting | Downloading | Extracting | Done
}
pub enum DownloadPhase {
Resolving,
Connecting,
Downloading,
Extracting,
Done,
}
pub enum DownloadResult {
Success {
output_path: PathBuf,
files: Vec<PathBuf>,
total_bytes: u64,
duration: Duration,
extracted: bool,
},
Cancelled {
partial_path: PathBuf,
downloaded_bytes: u64,
},
Error {
error: String,
partial_path: Option<PathBuf>,
},
}recibir URL
│
├─ ¿Content-Type es application/x-mpegURL o .m3u8?
│ → download_hls()
│
├─ ¿Content-Type es application/dash+xml o .mpd?
│ → download_dash()
│
├─ ¿Content-Type empieza con video/ audio/ application/octet?
│ ¿O tiene extensión de archivo?
│ → download_direct()
│
├─ ¿Content-Type es text/html?
│ → page_analyzer() busca enlaces
│ → si no encuentra → error "no se pudo extraer link"
│ → si encuentra → download_direct()
│
└─ Si no se puede determinar:
→ download_direct() (intenta igual)
pub fn analyze_page(html: &str, page_url: &Url) -> Vec<DetectedLink> {
// Estrategias de extracción, en orden:
// 1. Buscar <video> <source> src
// 2. Buscar <a href="*.mp4">, <a href="*.mkv">, etc
// 3. Buscar <a href="*.m3u8">, <a href="*.mpd">
// 4. Buscar scripts con config: window.__NUXT__, window.__INITIAL_STATE__
// 5. Buscar meta[property="og:video"]
// 6. Buscar iframe[src] con videos
// 7. Intentar site-plugins (MediaFire, etc.)
}
pub struct DetectedLink {
pub url: String,
pub source: LinkSource, // VideoTag | Anchor | Script | SitePlugin
pub filename: Option<String>,
pub quality: Option<String>,
}pub trait SitePlugin: Send + Sync {
fn name(&self) -> &'static str;
fn matches(&self, url: &Url) -> bool;
fn extract(&self, page_html: &str, page_url: &Url) -> Result<Vec<DetectedLink>, String>;
}
// Plugins built-in (registrados en orden de prioridad):
// 1. YoutubePlugin → llama a yt-dlp
// 2. MediaFirePlugin → busca #downloadButton href
// 3. MegaPlugin → API key + decrypt (futuro)
// 4. GoogleDrivePlugin → confirma descarga (futuro)YouTube no se puede scrapear. No hay <video src> en el HTML, no hay manifests públicos sin autenticación, el contenido está cifrado en segmentos. La única forma realista es usar yt-dlp, el estándar de facto.
yt-dlp (fork de youtube-dl) hace todo el trabajo pesado:
- Descifra la página de YouTube (JS, cifrado, signature decoding)
- Obtiene los manifests DASH/HLS reales
- Selecciona el mejor formato (video + audio separados)
- Los descarga y los multiplexa en un solo archivo
Ejemplo de comandos yt-dlp:
# Obtener lista de formatos disponibles
yt-dlp -F "https://youtube.com/watch?v=xxxx"
# Obtener URL directa del mejor formato (sin descargar)
yt-dlp -g --format "bestvideo+bestaudio/best" "https://youtube.com/watch?v=xxxx"
# Descargar y convertir a mp4
yt-dlp --format "bestvideo+bestaudio/best" --merge-output-format mp4 \
-o "~/Descargas/DarkDM/%(title)s.%(ext)s" "https://youtube.com/watch?v=xxxx"# ─── Descargar video (best quality automático) ───
darkdm descargar "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
# 1. Plugin detecta youtube.com
# 2. Llama a yt-dlp con format best
# 3. yt-dlp descarga video+audio, merge a mp4
# 4. → ~/Descargas/DarkDM/Rick Astley - Never Gonna Give You Up.mp4
# ─── YouTube + playlist ───
darkdm descargar "https://youtube.com/playlist?list=PL..."
# → yt-dlp descarga toda la playlist
# ─── YouTube + formato específico ───
darkdm descargar "https://youtube.com/watch?v=xxxx" --yt-format "bestvideo[height<=1080]+bestaudio/best[height<=1080]"
# ─── YouTube + solo audio ───
darkdm descargar "https://youtube.com/watch?v=xxxx" --yt-audio-only
# ─── YouTube + subtítulos ───
darkdm descargar "https://youtube.com/watch?v=xxxx" --yt-subs
# ─── YouTube + info (sin descargar) ───
darkdm info "https://youtube.com/watch?v=xxxx"
# → Título: Rick Astley - Never Gonna Give You Up
# → Duración: 3:32
# → Formatos: 18 (360p), 22 (720p), 137+140 (1080p DASH)
# → Mejor: 1080p DASH (video+audio)
# ─── YouTube + búsqueda ───
darkdm descargar "ytsearch:rick astley never gonna"
# → yt-dlp busca en YouTube y descarga el primer resultado
# ─── YouTube + lista de URLs ───
darkdm descargar "https://youtube.com/watch?v=xxxx" "https://youtube.com/watch?v=yyyy"
# → Cola de descargas
# ─── Otros sitios soportados por yt-dlp ───
darkdm descargar "https://vimeo.com/123456789"
darkdm descargar "https://www.tiktok.com/@user/video/123456789"
darkdm descargar "https://twitter.com/user/status/123456789"
darkdm descargar "https://www.twitch.tv/clips/..."
darkdm descargar "https://www.instagram.com/p/..."| Flag | Descripción | Default |
|---|---|---|
--yt-format <fmt> |
Formato específico de yt-dlp | bestvideo+bestaudio/best |
--yt-audio-only |
Solo descargar audio (mp3) | false |
--yt-subs |
Descargar subtítulos | false |
--yt-playlist-start <n> |
Empezar desde el item N de la playlist | 1 |
--yt-playlist-end <n> |
Terminar en el item N de la playlist | — |
--yt-output-template <t> |
Template de nombre de archivo | %(title)s.%(ext)s |
use std::process::Command;
use std::path::PathBuf;
pub struct YoutubePlugin;
impl SitePlugin for YoutubePlugin {
fn name(&self) -> &'static str {
"youtube-dlp"
}
fn matches(&self, url: &Url) -> bool {
let host = url.host_str().unwrap_or("");
// YouTube
host.contains("youtube.com") || host == "youtu.be" ||
// yt-dlp search
url.as_str().starts_with("ytsearch") ||
// Otros sitios populares que yt-dlp soporta
host.contains("vimeo.com") ||
host.contains("twitch.tv") ||
host.contains("tiktok.com") ||
host.contains("twitter.com") ||
host.contains("x.com") ||
host.contains("instagram.com") ||
host.contains("facebook.com") ||
host.contains("dailymotion.com")
}
fn extract(&self, page_html: &str, page_url: &Url) -> Result<Vec<DetectedLink>, String> {
// Para yt-dlp no necesitamos el HTML del todo —
// yt-dlp obtiene la info por su cuenta
Ok(vec![DetectedLink {
url: page_url.as_str().to_string(),
source: LinkSource::SitePlugin("youtube-dlp"),
filename: None,
quality: None,
}])
}
}
pub struct YoutubeOptions {
pub format: Option<String>, // --yt-format
pub audio_only: bool, // --yt-audio-only
pub subs: bool, // --yt-subs
pub playlist_start: Option<u32>, // --yt-playlist-start
pub playlist_end: Option<u32>, // --yt-playlist-end
pub output_template: Option<String>,// --yt-output-template
}
impl Default for YoutubeOptions {
fn default() -> Self {
Self {
format: Some("bestvideo+bestaudio/best".into()),
audio_only: false,
subs: false,
playlist_start: None,
playlist_end: None,
output_template: Some("%(title)s.%(ext)s".into()),
}
}
}
/// Descarga con yt-dlp y reporta progreso
pub fn download_with_ytdlp(
url: &str,
options: &YoutubeOptions,
output_dir: &Path,
progress: impl Fn(ProgressInfo),
) -> Result<DownloadResult, String> {
// Verificar que yt-dlp existe
let which = Command::new("which")
.arg("yt-dlp")
.output()
.map_err(|_| "yt-dlp no está instalado. Instálalo con:\n pip install yt-dlp\n # o\n sudo pacman -S yt-dlp".to_string())?;
if !which.status.success() {
return Err("yt-dlp no está instalado".to_string());
}
let output_template = options.output_template
.clone()
.unwrap_or_else(|| "%(title)s.%(ext)s".into());
let output_path = output_dir.join(&output_template);
let mut cmd = Command::new("yt-dlp");
cmd.args([
"--format", options.format.as_deref().unwrap_or("bestvideo+bestaudio/best"),
"--merge-output-format", "mp4",
"--output", output_path.to_str().unwrap_or(""),
"--progress-template", "console:%(progress.eta)s %(progress.speed)s",
"--no-mtime", // no modificar timestamp
"--embed-thumbnail", // thumbnail en el archivo
"--embed-metadata", // metadatos en el archivo
]);
if options.audio_only {
cmd.args(["--extract-audio", "--audio-format", "mp3"]);
}
if options.subs {
cmd.args(["--write-subs", "--sub-langs", "all"]);
}
if let Some(start) = options.playlist_start {
cmd.args(["--playlist-start", &start.to_string()]);
}
if let Some(end) = options.playlist_end {
cmd.args(["--playlist-end", &end.to_string()]);
}
// Cookies del navegador (para contenido restringido por edad)
cmd.args(["--cookies-from-browser", "vivaldi"]);
cmd.arg(url);
// Ejecutar y reportar progreso
progress(ProgressInfo {
bytes_downloaded: 0,
total_bytes: None,
speed: 0.0,
eta: None,
phase: DownloadPhase::Downloading,
});
let status = cmd.status()
.map_err(|e| format!("Error ejecutando yt-dlp: {}", e))?;
if !status.success() {
return Err(format!("yt-dlp falló con código: {}", status));
}
// Encontrar el archivo descargado
let files = find_files_in_dir(output_dir);
let total_size: u64 = files.iter()
.filter_map(|f| std::fs::metadata(f).ok().map(|m| m.len()))
.sum();
Ok(DownloadResult {
output_path: output_dir.to_path_buf(),
files,
total_bytes: total_size,
duration: Duration::from_secs(0),
extracted: false,
})
}
/// Función info para YouTube (sin descargar)
pub fn get_ytdlp_info(url: &str) -> Result<serde_json::Value, String> {
let output = Command::new("yt-dlp")
.args(["--dump-json", "--no-download", url])
.output()
.map_err(|e| format!("yt-dlp falló: {}", e))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).to_string());
}
serde_json::from_slice(&output.stdout)
.map_err(|e| format!("Error parseando JSON de yt-dlp: {}", e))
}$ darkdm descargar "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
🔍 Detectado: YouTube (vía yt-dlp)
📹 Rick Astley - Never Gonna Give You Up
Duración: 3:32 | Mejor calidad: 1080p DASH
⬇️ Descargando...
[youtube] dQw4w9WgXcQ: Downloading webpage
[youtube] dQw4w9WgXcQ: Downloading android player API JSON
[download] Destination: Rick Astley - Never Gonna Give You Up.f137.mp4
[download] ━━━━━━━━━━━━━━━━━━━╸━━━━━ 65% • 8.2 MB/s • ETA: 0:45
[download] Destination: Rick Astley - Never Gonna Give You Up.f140.m4a
[download] ━━━━━━━━━━━━━━━━━━━━━━━━━ 100% • 3.4 MB/s
[Merger] Merging video + audio into Rick Astley - Never Gonna Give You Up.mp4
✅ Completo: ~/Descargas/DarkDM/Rick Astley - Never Gonna Give You Up.mp4
Tamaño: 42 MB | Duración: 3:32 | 1080p$ darkdm info "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
📹 Rick Astley - Never Gonna Give You Up
Canal: Rick Astley
Duración: 3:32
Publicado: 2009-10-25
Vistas: 1.5B
Formatos disponibles:
18 360p mp4 video+audio 22 MB
22 720p mp4 video+audio 45 MB
137 1080p mp4 video only 38 MB ← mejor video
140 m4a audio only 3 MB ← mejor audio
💡 Recomendado: 137+140 (1080p DASH, 42 MB)A diferencia del resto del engine que es Rust nativo, YouTube requiere yt-dlp.
# Instalación
pip install yt-dlp
# o
sudo pacman -S yt-dlp
# o
sudo apt install yt-dlpSi no está instalado:
darkdm descargar "https://youtube.com/watch?v=xxxx"
# ⚠️ yt-dlp no está instalado
# → Para descargar de YouTube necesitas yt-dlp:
# pip install yt-dlp
# → O usa la extensión de Chrome (no requiere yt-dlp)yt-dlp soporta más de 1000 sitios. El plugin YouTube también funcionará para:
| Sitio | URL de ejemplo |
|---|---|
| YouTube | youtube.com/watch?v=... |
| Vimeo | vimeo.com/123456789 |
| TikTok | tiktok.com/@user/video/... |
| Twitter/X | twitter.com/user/status/... |
| Twitch | twitch.tv/videos/... |
instagram.com/p/... |
|
facebook.com/watch/... |
|
| Dailymotion | dailymotion.com/video/... |
| Bilibili | bilibili.com/video/... |
reddit.com/r/.../comments/... |
Cualquier URL que yt-dlp soporte, DarkDM también.
Cubierto en detalle en las secciones 6a (Dynamic Piece-Splitting), 6b (TransactedIO), y 6c (Resume automático) dentro de los Patrones de diseño estándar.
pub fn extract(path: &Path, password: Option<&str>) -> Result<Vec<PathBuf>, String> {
match detect_archive_type(path) {
ArchiveType::Zip => extract_zip(path),
ArchiveType::Rar => extract_rar(path, password),
ArchiveType::SevenZ => extract_7z(path, password),
ArchiveType::TarGz => extract_targz(path),
ArchiveType::TarXz => extract_tarxz(path),
ArchiveType::None => Ok(vec![]), // no es archive
}
}src-tauri/
src/
lib.rs ← Tauri app + engine público
bin/cli.rs ← darkdm CLI
downloader/
mod.rs ← Orchestrator principal
engine.rs ← reqwest download core
segmented.rs ← Multi-hilo con Range
resume.rs ← Resume logic
hls.rs ← HLS parser/downloader
dash.rs ← DASH parser/downloader
page_analyzer.rs ← Generic page scraper
plugins/
mod.rs ← Plugin trait
mediafire.rs ← MediaFire plugin
mega.rs ← Mega plugin (futuro)
extract.rs ← Archive extraction
progress.rs ← Progress types + callbacks
// lib.rs
pub mod downloader;
use downloader::{descargar, DownloadOptions};
#[tauri::command]
fn download_url(url: String, dir: Option<String>, password: Option<String>) {
let options = DownloadOptions {
url,
output_dir: dir.map(PathBuf::from).unwrap_or_default(),
password,
..Default::default()
};
// Lanzar en thread separado, reportar progreso via eventos Tauri
tauri::async_runtime::spawn(async move {
let result = descargar(options).await;
// Emitir evento "download-complete"
});
}[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["stream"] }
scraper = "0.20" # HTML parsing (page analyzer)
indicatif = "0.17" # Progress bar terminal
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
zip = "2" # ZIP extraction
tar = "0.4" # tar.gz/xz
flate2 = "1" # gzip
xz2 = "0.1" # xz
url = "2" # URL parsing
mime_guess = "2" # Content-Type detection
chrono = "0.4" # TimestampsDecisión: El core del CLI es un download engine universal que acepta URLs directas. La extracción de páginas web es una feature secundaria.
Rationale: Como IDM, la extensión de Chrome es la que captura las URLs con todos sus headers. El engine solo necesita descargar bien.
Decisión: Usar reqwest para todo el HTTP.
Rationale: Control total (headers, streaming, Range), sin dependencias externas, comparte tipos con Tauri.
Decisión: Usar el algoritmo de splitting dinámico de XDM en lugar de dividir el archivo en N partes iguales al inicio.
Rationale: El splitting dinámico redistribuye trabajo automáticamente si un servidor es lento en un rango específico. Empieza con 1 pieza y divide la más grande conforme los workers terminan. Referencia: HTTPDownloaderBase.cs de XDM.
Decisión: Persistir el estado de las piezas con rotación de 3 archivos y marcador END validado.
Rationale: Si el proceso muere durante escritura de estado, el archivo anterior queda intacto gracias a rename(2) atómico en Linux. Referencia: TransactedIO.cs de XDM.
Decisión: Siempre enviar Accept-Encoding: identity en los requests del download engine.
Rationale: Si el servidor comprime la respuesta, el Content-Length corresponde al tamaño comprimido, rompiendo los cálculos de Range. No hay excepción a esta regla. Referencia: SingleSourceHTTPDownloader.cs de XDM.
Decisión: El worker (PieceWorker) solo habla con el trait PieceCallback, nunca con el orchestrator directamente.
Rationale: Desacopla el worker del downloader. Permite tests unitarios con mocks. Referencia: IPieceCallback.cs de XDM.
Decisión: Cuando un worker termina su pieza y el servidor ya mandó datos del rango siguiente, adoptar la pieza adyacente sin cerrar la conexión TCP.
Rationale: Reduce latencia de reconexión en servidores con alta latencia. Referencia: PieceGrabber.cs de XDM.
Decisión: El page analyzer busca enlaces genéricamente (video tags, anchors, scripts). Los site-plugins (MediaFire, YouTube) son un extra.
Rationale: No podemos tener scrapers para cada sitio. Lo genérico cubre el 90%. Los plugins cubren sitios populares.
Decisión: Si ya se descargaron bytes y el servidor responde 401/403, es SessionExpired — diferente a AccessDenied.
Rationale: El usuario necesita mensajes de error accionables. SessionExpired le dice "renueva tu cookie", AccessDenied le dice "no tienes acceso". Referencia: PieceGrabber.cs de XDM.
Decisión: Verificar espacio disponible en temp y output antes de iniciar la descarga.
Rationale: Fallar rápido es mejor que fallar a los 2 GB. Referencia: SingleSourceHTTPDownloader.cs de XDM.
Con --json, cualquier comando debe output JSON parseable:
darkdm descargar "https://..." --json
# {"status":"success","files":[{"path":"...","size":123}],"duration":154}| XDM (C#) | DarkDM (Rust) | Notas |
|---|---|---|
Piece.cs |
downloader/piece.rs |
Modelo de datos, AtomicU64 para downloaded |
PieceGrabber.cs |
downloader/stages/piece_worker.rs |
Worker por pieza |
HTTPDownloaderBase.cs |
downloader/stages/download_engine.rs |
Orchestrator + splitting dinámico |
IPieceCallback |
PieceCallback trait |
Inversion of control |
TransactedIO.cs |
downloader/transacted_io.rs |
3-file rotation + END marker |
SpeedLimiter.cs |
downloader/speed_limiter.rs |
Wakeable sleep con Notify |
ProbeResult |
downloader/probe.rs |
Struct del primer HEAD/GET |
SingleSourceHTTPDownloader |
downloader/stages/direct.rs |
Descarga directa single-source |
DualSourceHTTPDownloader |
N/A | lo hace yt-dlp |
MultiSourceHLSDownloader |
downloader/stages/hls.rs |
Con byte-range support |
MultiSourceDASHDownloader |
downloader/stages/dash.rs |
|
HlsParser.cs |
downloader/parsers/hls.rs |
Con #EXT-X-BYTERANGE |
MpdParser.cs |
downloader/parsers/dash.rs |
SegmentTemplate |
DownloadQueue.cs + Scheduler |
downloader/queue.rs |
Cola + horario semanal |
IpcHttpMessageProcessor |
native-host/src/server.rs |
Ya existe |
FileNameFetchMode |
resolve_output_path() |
Auto-rename colisiones |
WinHttpClient / WinInetClient |
N/A | No necesario en Linux |
- [Riesgo] Servidores sin Range → Fallback automático a single-thread tras ProbeResult.
- [Riesgo] Rate limiting con multi-hilo →
--threadsconfigurable, default conservador (4), splitting dinámico reduce el problema. - [Riesgo] Content-Length dinámico → Si
Content-Rangedifiere del tamaño guardado →FileChangedOnServer→ reinicio. - [Riesgo] Estado corrupto tras crash → TransactedIO con rotación de 3 archivos y validación END marker.
- [Trade-off] Rust compile time → ~3min la primera vez. Se compila una vez, corre sin dependencias.
- [Trade-off] RAR sigue siendo externo → unrar CLI, no hay crate Rust maduro para RAR5.
-
darkdm descargar <direct_url>descarga con barra de progreso - Dynamic piece-splitting funciona (splitting dinámico, no estático)
-
ContinueAdjacentPiecereutiliza conexiones TCP - TransactedIO persiste estado crash-safe
-
Accept-Encoding: identityen todos los requests del engine - Resume entre sesiones funciona (TransactedIO + deserialize pieces)
- Resume dentro de pieza funciona (seek + Range offset)
- ProbeResult detecta: tamaño, resumable, filename, content-type
- Text redirect detection en primer request
- Session expiry = error diferenciado de 403 normal
- Disk space check antes de empezar
- Auto-rename en conflicto de nombre de archivo
-
darkdm descargar <mediafire_url> --password xextrae RAR -
darkdm descargar <hls_url>con byte-range HLS support -
darkdm descargar <url> --jsonoutput JSON -
darkdm info <url>muestra info sin descargar -
./init.shpasa - Engine funciona desde Tauri