From 33e1ba4d65191f24c659005ca6cac0ef253df1a3 Mon Sep 17 00:00:00 2001 From: Bjay kamwa Watanabe Date: Tue, 25 Aug 2026 16:23:15 +0200 Subject: [PATCH] feat(asset): add asset download command --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 12 ++- src/commands/asset/download.rs | 170 +++++++++++++++++++++++++++++++++ src/commands/asset/mod.rs | 5 +- src/main.rs | 6 ++ 6 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/commands/asset/download.rs diff --git a/Cargo.lock b/Cargo.lock index 791f56d..e9c7cdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1914,6 +1914,7 @@ dependencies = [ "crossterm", "dirs", "ffmpeg-sidecar", + "futures-util", "log", "mime_guess", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index b07ebed..c34b133 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ mime_guess = "2" time = { version = "0.3", features = ["formatting"] } uuid = { version = "1", features = ["v4"] } bytes = "1" +futures-util = "0.3" tokio-util = { version = "0.7", features = ["codec"] } url = "2" ffmpeg-sidecar = "2.2" diff --git a/README.md b/README.md index 9a175df..3dbba66 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,16 @@ tellers upload /path/to/media_folder Files ≥ 10 MiB use **multipart S3 upload** (presigned part URLs, then complete); smaller files use a single presigned PUT. +### Asset Download Command + +Download a video asset using its asset ID: + +```bash +tellers asset download --output /path/to/video.mp4 +``` + +Use `--quality highest|lowest|original|480p|720p|1080p` to select a rendition. Existing files are protected by default; pass `--force` to replace one. + ## Implementation Notes - Argument parsing via Clap 4.x. See `clap` docs: [docs.rs/clap](https://docs.rs/clap/latest/clap/) @@ -114,4 +124,4 @@ Generate the client crate: ```bash scripts/generate_api.sh -``` \ No newline at end of file +``` diff --git a/src/commands/asset/download.rs b/src/commands/asset/download.rs new file mode 100644 index 0000000..6504ee2 --- /dev/null +++ b/src/commands/asset/download.rs @@ -0,0 +1,170 @@ +use std::path::PathBuf; + +use clap::{Args, ValueEnum}; +use tellers_api_client::apis::accepts_api_key_api as api; +use tellers_api_client::models::asset_download_url_batch_request::Quality; +use tellers_api_client::models::AssetDownloadUrlBatchRequest; +use tokio::io::AsyncWriteExt; + +use crate::commands::api_config; + +#[derive(Clone, Debug, ValueEnum)] +pub enum DownloadQuality { + #[value(name = "480p")] + P480, + #[value(name = "720p")] + P720, + #[value(name = "1080p")] + P1080, + Original, + Highest, + Lowest, +} + +impl From for Quality { + fn from(value: DownloadQuality) -> Self { + match value { + DownloadQuality::P480 => Self::Variant480p, + DownloadQuality::P720 => Self::Variant720p, + DownloadQuality::P1080 => Self::Variant1080p, + DownloadQuality::Original => Self::Original, + DownloadQuality::Highest => Self::Highest, + DownloadQuality::Lowest => Self::Lowest, + } + } +} + +#[derive(Args, Debug)] +pub struct DownloadArgs { + /// Asset ID to download. + pub asset_id: String, + + /// Local destination. Defaults to the asset ID in the current directory. + #[arg(short, long)] + pub output: Option, + + /// Video rendition to download. + #[arg(long, value_enum, default_value = "highest")] + pub quality: DownloadQuality, + + /// Replace an existing destination file. + #[arg(long)] + pub force: bool, + + #[arg(long, env = "TELLERS_API_KEY", hide = true)] + pub api_key: Option, + + #[arg(long, env = "TELLERS_AUTH_BEARER", hide = true)] + pub auth_bearer: Option, +} + +pub fn run(args: DownloadArgs) -> Result<(), String> { + let destination = args.output.unwrap_or_else(|| PathBuf::from(&args.asset_id)); + if destination.exists() && !args.force { + return Err(format!( + "destination already exists: {}; use --force to replace it", + destination.display() + )); + } + + let cfg = api_config::create_config(); + let api_key = api_config::get_api_key(args.api_key)?; + let bearer_header = api_config::get_bearer_header(args.auth_bearer); + let asset_id = args.asset_id; + let quality = args.quality.into(); + let partial = partial_path(&destination); + + tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async move { + let mut request = AssetDownloadUrlBatchRequest::new(vec![asset_id.clone()]); + request.quality = Some(quality); + + let response = api::get_asset_download_url_batch_json_asset_download_urls_post( + &cfg, + request, + Some(&api_key), + bearer_header.as_deref(), + ) + .await + .map_err(|e| format!("failed to resolve asset download URL: {}", e))?; + + let item = response + .items + .into_iter() + .next() + .ok_or_else(|| "download URL response contained no item".to_string())?; + if item.error { + return Err(format!( + "asset {} cannot be downloaded: {}", + asset_id, + item.error_message + .flatten() + .unwrap_or_else(|| "unknown error".to_string()) + )); + } + let url = item + .download + .flatten() + .ok_or_else(|| format!("asset {} has no download URL", asset_id))? + .url + .to_string(); + + let result = download_to_file(&url, &partial, &destination, args.force).await; + if result.is_err() { + let _ = tokio::fs::remove_file(&partial).await; + } + result + }) +} + +fn partial_path(destination: &std::path::Path) -> PathBuf { + let mut partial = destination.as_os_str().to_os_string(); + partial.push(".part"); + PathBuf::from(partial) +} + +async fn download_to_file( + url: &str, + partial: &std::path::Path, + destination: &std::path::Path, + force: bool, +) -> Result<(), String> { + let response = reqwest::get(url) + .await + .map_err(|e| format!("download request failed: {}", e))? + .error_for_status() + .map_err(|e| format!("download request failed: {}", e))?; + + let mut file = tokio::fs::File::create(partial) + .await + .map_err(|e| format!("failed to create {}: {}", partial.display(), e))?; + let mut stream = response.bytes_stream(); + while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await { + let chunk = chunk.map_err(|e| format!("download failed: {}", e))?; + file.write_all(&chunk) + .await + .map_err(|e| format!("failed writing {}: {}", partial.display(), e))?; + } + file.flush() + .await + .map_err(|e| format!("failed flushing {}: {}", partial.display(), e))?; + drop(file); + + if destination.exists() { + if !force { + return Err(format!( + "destination appeared during download: {}; use --force to replace it", + destination.display() + )); + } + tokio::fs::remove_file(destination) + .await + .map_err(|e| format!("failed replacing {}: {}", destination.display(), e))?; + } + tokio::fs::rename(partial, destination) + .await + .map_err(|e| format!("failed finalizing {}: {}", destination.display(), e))?; + println!("downloaded asset to {}", destination.display()); + Ok(()) +} diff --git a/src/commands/asset/mod.rs b/src/commands/asset/mod.rs index 4513d31..5d2ceee 100644 --- a/src/commands/asset/mod.rs +++ b/src/commands/asset/mod.rs @@ -1,8 +1,10 @@ use clap::{Args, Subcommand}; mod list; +mod download; mod set_anonymous_read; +pub use download::{run as download_run, DownloadArgs}; pub use list::{run as list_run, ListArgs}; pub use set_anonymous_read::{run as set_anonymous_read_run, SetAnonymousReadArgs}; @@ -15,7 +17,8 @@ pub struct AssetArgs { #[derive(Subcommand, Debug)] pub enum AssetCommand { List(ListArgs), + /// Download an asset to a local file. + Download(DownloadArgs), /// Set anonymous read permission for an asset (allow unauthenticated read). SetAnonymousRead(SetAnonymousReadArgs), } - diff --git a/src/main.rs b/src/main.rs index eb2f93c..cc6e30d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,12 @@ fn main() { std::process::exit(1); } } + commands::asset::AssetCommand::Download(args) => { + if let Err(error) = commands::asset::download_run(args) { + eprintln!("error: {}", error); + std::process::exit(1); + } + } commands::asset::AssetCommand::SetAnonymousRead(args) => { if let Err(error) = commands::asset::set_anonymous_read_run(args) { eprintln!("error: {}", error);