Rust 語言@android phone (no-root)實作純 RAM 下載

常在 youtube 及一些線上看的串流平台下載影音後再找時間觀看, 平時用 yt-dlp 下載也沒發現什麼問題, 有一次觀看下載過程時發現 yt-dlp 會產生大量暫存檔, 這對手機內的ssd壽命傷害極大,苦於手機沒有 root, 無法調用 ramdisk,所以試著用 python 搭配使用 sponge 可以做到大約 530MB 才回寫 ssd 一次, 若影片小於 530MB 則下載全程都在 dram 內進行, 為了達成盡善盡美, 改用 rust 並模擬 sponge 功能達成下載全程都在 dram 內完成, 以下是rust 程式碼, 可以根據自己手機 dram 大小去調整緩衝區大小, 我的手機是 Samsung 22ultra 12GB dram

附圖可以看到下載過程中 dram 的用量與下載的檔案大小約略一致(截圖時有時間差), 第三張圖可以看到 I/O次數為零, 代表下載過程中零回寫直到下載完成
Rust 語言@android phone (no-root)實作純 RAM 下載
Rust 語言@android phone (no-root)實作純 RAM 下載

Rust 語言@android phone (no-root)實作純 RAM 下載

use std::io::{self, Read, Write, BufWriter};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

// ========== 配置常數 ==========
const MAX_RAM_MB: usize = 2048;
const DISK_WRITE_BUFFER_SIZE: usize = 128 * 1024 * 1024; // 128MB

// 預設輸出路徑選項
const DEFAULT_PATH_OPTIONS: [(&str, &str); 2] = [
  ("1", "當前目錄"),
  ("2", "/data/data/com.termux/files/home/storage/downloads/linux"),
];

// ========== 工具函數 ==========
fn prompt_user(prompt: &str) -> String {
  print!("{}", prompt);
  io::stdout().flush().unwrap();

  let mut input = String::new();
  io::stdin().read_line(&mut input).unwrap();
  input.trim().to_string()
}

fn prompt_user_with_default(prompt: &str, default_value: &str) -> String {
  print!("{}", prompt);
  io::stdout().flush().unwrap();

  let mut input = String::new();
  io::stdin().read_line(&mut input).unwrap();

  let trimmed = input.trim();
  if trimmed.is_empty() {
    default_value.to_string()
  } else {
    trimmed.to_string()
  }
}

fn is_m3u8_url(url: &str) -> bool {
  url.contains(".m3u8")
}

fn get_video_duration(url: &str) -> Option {
  let output = Command::new("ffprobe")
    .args(&[
      "-v", "error",
      "-show_entries", "format=duration",
      "-of", "default=noprint_wrappers=1:nokey=1",
      url
    ])
    .output()
    .ok()?;

  let duration_str = String::from_utf8_lossy(&output.stdout);
  duration_str.trim().parse::().ok()
}

// ========== 路徑選擇函數 ==========
fn select_output_directory() -> PathBuf {
  println!("\n=== 選擇輸出路徑 ===");

  // 顯示預設選項
  for (index, description) in &DEFAULT_PATH_OPTIONS {
    println!("{}. {}", index, description);
  }
  println!("3. 自訂路徑");

  loop {
    let choice = prompt_user("\n請選擇 (1-3): ");

    match choice.as_str() {
      "1" => {
        // 當前目錄
        let current_dir = std::env::current_dir()
          .unwrap_or_else(|_| PathBuf::from("."));
        println!("選擇: 當前目錄 ({})", current_dir.display());
        return current_dir;
      }
      "2" => {
        // 預設路徑
        let path = PathBuf::from("/data/data/com.termux/files/home/storage/downloads/linux");
        println!("選擇: {}", path.display());

        // 檢查目錄是否存在,不存在則建立
        if !path.exists() {
          println!("目錄不存在,正在建立...");
          if let Err(e) = std::fs::create_dir_all(&path) {
            eprintln!("建立目錄失敗: {},將使用當前目錄", e);
            return std::env::current_dir()
              .unwrap_or_else(|_| PathBuf::from("."));
          }
        }
        return path;
      }
      "3" => {
        // 自訂路徑
        let custom_path = prompt_user("請輸入自訂路徑: ");
        if custom_path.trim().is_empty() {
          println!("路徑不能為空,請重新選擇");
          continue;
        }

        let path = PathBuf::from(custom_path.trim());
        println!("選擇: {}", path.display());

        // 檢查目錄是否存在,不存在則建立
        if !path.exists() {
          println!("目錄不存在,正在建立...");
          if let Err(e) = std::fs::create_dir_all(&path) {
            eprintln!("建立目錄失敗: {},請重新選擇", e);
            continue;
          }
        }
        return path;
      }
      _ => {
        println!("無效選項,請輸入 1、2 或 3");
      }
    }
  }
}

// ========== 進度顯示函數 ==========
fn show_download_progress(
  data_size: usize,
  start_time: Instant,
  last_update: &mut Instant,
  prefix: &str
) -> bool {
  if last_update.elapsed() < Duration::from_millis(500) {
    return false;
  }

  let megabytes = data_size as f64 / (1024.0 * 1024.0);
  let elapsed_seconds = start_time.elapsed().as_secs_f64().max(0.01);
  let speed_mbps = megabytes / elapsed_seconds;

  print!("\r{} | {:7.1} MB | {:5.2} MB/s", prefix, megabytes, speed_mbps);
  io::stdout().flush().unwrap();

  *last_update = Instant::now();
  true
}

// ========== 核心下載函數 ==========
fn download_with_ram_buffering(url: &str, filename: &str, output_dir: &Path) {
  println!("\n=== 啟動記憶體緩衝下載模式 (MKV 封裝) ===");
  println!("預計寫入緩衝: {} MB", DISK_WRITE_BUFFER_SIZE / 1024 / 1024);
  println!("輸出路徑: {}", output_dir.display());

  let output_path = output_dir.join(format!("{}.mkv", filename));

  // 階段 1: 下載影片到 RAM
  println!("正在下載並暫存於 RAM...");
  let video_data = match download_video_to_ram(url) {
    Some(data) => data,
    None => {
      eprintln!("\n\n下載失敗,請檢查 URL 或更新 yt-dlp。");
      return;
    }
  };

  // 階段 2: 在 RAM 中重新封裝影片
  println!("\n\n下載完成!正在進行 RAM 內 remux(建立索引)...");
  match remux_in_ram(&video_data) {
    Some(remuxed_data) => {
      save_to_disk(&output_path, &remuxed_data, "Remux 完成!正在寫入硬碟(僅一次)...");
      println!("\n完成!");
      println!("檔案路徑: {}", output_path.display());
      println!("總大小: {:.1} MB", remuxed_data.len() as f64 / 1024.0 / 1024.0);
    }
    None => {
      eprintln!("Remux 失敗,直接寫入原始檔案");
      save_to_disk(&output_path, &video_data, "寫入原始檔案...");
    }
  }
}

fn download_video_to_ram(url: &str) -> Option> {
  let mut ytdlp_process = Command::new("yt-dlp")
    .args(&[
      "-f", "bestvideo+bestaudio/best",
      "--merge-output-format", "mkv",
      "--downloader", "ffmpeg",
      "--downloader-args", "ffmpeg:-f matroska",
      "-o", "-",
      url
    ])
    .stdout(Stdio::piped())
    .stderr(Stdio::null())
    .spawn()
    .expect("無法啟動 yt-dlp");

  let mut stdout = ytdlp_process.stdout.take()?;
  let mut buffer = Vec::new();
  let mut chunk = [0u8; 2 * 1024 * 1024]; // 2MB 塊

  let start_time = Instant::now();
  let mut last_progress_update = Instant::now();

  loop {
    match stdout.read(&mut chunk) {
      Ok(0) => break, // 讀取結束
      Ok(bytes_read) => {
        buffer.extend_from_slice(&chunk[..bytes_read]);

        show_download_progress(
          buffer.len(),
          start_time,
          &mut last_progress_update,
          "已下載"
        );
      }
      Err(_) => break,
    }
  }

  if !ytdlp_process.wait().expect("等待下載結束失敗").success() {
    return None;
  }

  Some(buffer)
}

fn remux_in_ram(input_data: &[u8]) -> Option> {
  let mut ffmpeg_process = Command::new("ffmpeg")
    .args(&[
      "-i", "pipe:0",
      "-c", "copy",
      "-f", "matroska",
      "pipe:1"
    ])
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .stderr(Stdio::null())
    .spawn()
    .expect("無法啟動 ffmpeg remux");

  let mut stdin = ffmpeg_process.stdin.take()?;
  let mut stdout = ffmpeg_process.stdout.take()?;

  // 在背景執行緒寫入資料
  let input_clone = input_data.to_vec();
  let write_thread = std::thread::spawn(move || {
    stdin.write_all(&input_clone).expect("寫入 ffmpeg stdin 失敗");
  });

  // 讀取 remux 後的資料
  let mut output_buffer = Vec::new();
  let mut read_chunk = [0u8; 2 * 1024 * 1024];

  while let Ok(bytes_read) = stdout.read(&mut read_chunk) {
    if bytes_read == 0 {
      break;
    }
    output_buffer.extend_from_slice(&read_chunk[..bytes_read]);
  }

  write_thread.join().expect("寫入執行緒失敗");

  if ffmpeg_process.wait().expect("等待 ffmpeg 結束失敗").success() {
    Some(output_buffer)
  } else {
    None
  }
}

fn save_to_disk(file_path: &Path, data: &[u8], message: &str) {
  println!("{}", message);

  // 確保目錄存在
  if let Some(parent) = file_path.parent() {
    if !parent.exists() {
      if let Err(e) = std::fs::create_dir_all(parent) {
        eprintln!("建立目錄失敗: {}", e);
        return;
      }
    }
  }

  let file = std::fs::File::create(file_path).expect("建立檔案失敗");
  let mut writer = BufWriter::with_capacity(DISK_WRITE_BUFFER_SIZE, file);

  writer.write_all(data).expect("寫入失敗");
  writer.flush().expect("刷新失敗");
}

// ========== M3U8 專用下載函數 ==========
fn download_m3u8_with_ram(url: &str, filename: &str, output_dir: &Path) {
  let trim_start = prompt_user_with_default("片頭裁切秒數(Enter = 0): ", "0")
    .parse::()
    .unwrap_or(0.0);

  let trim_end = prompt_user_with_default("片尾裁切秒數(Enter = 0): ", "0")
    .parse::()
    .unwrap_or(0.0);

  let output_path = output_dir.join(format!("{}.mp4", filename));
  let video_data = match download_m3u8_to_ram(url, trim_start, trim_end) {
    Some(data) => data,
    None => return,
  };

  println!("\n下載完成,寫入檔案…");

  // 確保目錄存在
  if let Some(parent) = output_path.parent() {
    if !parent.exists() {
      if let Err(e) = std::fs::create_dir_all(parent) {
        eprintln!("建立目錄失敗: {}", e);
        return;
      }
    }
  }

  std::fs::write(&output_path, &video_data).expect("寫入檔案失敗");

  println!("\n完成:{}({:.1} MB)", 
       output_path.display(), 
       video_data.len() as f64 / 1024.0 / 1024.0);
}

fn download_m3u8_to_ram(url: &str, trim_start: f64, trim_end: f64) -> Option> {
  let mut ffmpeg_command = Command::new("ffmpeg");

  ffmpeg_command
    .arg("-loglevel").arg("error");

  // 設定裁切參數
  if trim_start > 0.0 {
    ffmpeg_command.arg("-ss").arg(format!("{}", trim_start));
  }

  if trim_end > 0.0 {
    println!("檢查影片長度...");
    if let Some(total_duration) = get_video_duration(url) {
      if total_duration > trim_end {
        let end_time = total_duration - trim_end;
        ffmpeg_command.arg("-to").arg(format!("{}", end_time));
        println!("影片總長: {:.1} 秒,裁切後: {:.1} 秒", 
            total_duration, end_time - trim_start);
      } else {
        eprintln!("警告:影片總長 {:.1} 秒,裁切秒數不合法", total_duration);
        return None;
      }
    } else {
      eprintln!("無法取得影片長度,跳過片尾裁切");
    }
  }

  ffmpeg_command
    .args(&["-i", url])
    .args(&["-c", "copy"])
    .args(&["-f", "mpegts"])
    .arg("pipe:1")
    .stdout(Stdio::piped())
    .stderr(Stdio::null());

  println!("\n開始 RAM-only 下載 (純 m3u8 模式)");

  let mut process = ffmpeg_command.spawn().expect("無法啟動 ffmpeg");
  let mut stdout = process.stdout.take()?;

  let mut buffer = Vec::new();
  let mut chunk = [0u8; 64 * 1024];

  let start_time = Instant::now();
  let mut last_progress_update = Instant::now();

  loop {
    match stdout.read(&mut chunk) {
      Ok(0) => break, // 讀取結束
      Ok(bytes_read) => {
        buffer.extend_from_slice(&chunk[..bytes_read]);

        // 檢查 RAM 限制
        if buffer.len() > MAX_RAM_MB * 1024 * 1024 {
          eprintln!("\nRAM 已達上限,停止下載");
          let _ = process.kill();
          return None;
        }

        show_download_progress(
          buffer.len(),
          start_time,
          &mut last_progress_update,
          "下載中"
        );
      }
      Err(e) => {
        eprintln!("\n讀取錯誤: {}", e);
        let _ = process.kill();
        return None;
      }
    }
  }

  if !process.wait().unwrap().success() {
    eprintln!("ffmpeg 執行失敗");
    return None;
  }

  Some(buffer)
}

// ========== 主函數 ==========
fn main() {
  println!("=== dlvideo-ram (SSD Protection Build) ===\n");

  // 1. 選擇輸出路徑
  let output_directory = select_output_directory();

  // 2. 輸入影片資訊
  let video_url = prompt_user("\n影片網址: ");
  let output_filename = prompt_user("輸出檔名(不含副檔名): ");

  // 3. 根據 URL 類型選擇下載方式
  if is_m3u8_url(&video_url) {
    download_m3u8_with_ram(&video_url, &output_filename, &output_directory);
  } else {
    download_with_ram_buffering(&video_url, &output_filename, &output_directory);
  }
}
2025-12-26 17:34 發佈
請問,沒有 root ,怎麼賦予執行的權限?
member.7 wrote:
請問,沒有 ...(恕刪)


可以將 compile 完的程式 chmod + x 後移動到 usr/bin 下就可以執行了
在 termux 下的路徑應該會在
/data/data/com.termux/files/usr/bin
內文搜尋
X
評分
評分
複製連結
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?