BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add blob fetching via tempfile and cleanup

+602 -572
+1
src-tauri/Cargo.lock
··· 3852 3852 "dirs", 3853 3853 "fastembed", 3854 3854 "hf-hub 0.5.0", 3855 + "image", 3855 3856 "jacquard", 3856 3857 "reqwest 0.12.28", 3857 3858 "rusqlite",
+1
src-tauri/Cargo.toml
··· 40 40 thiserror = "2.0.18" 41 41 uuid = { version = "1", features = ["v4"] } 42 42 dirs = "6.0.0" 43 + image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } 43 44 44 45 # TODO: add this later 45 46 # [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+12
src-tauri/src/commands/explorer.rs
··· 36 36 } 37 37 38 38 #[tauri::command] 39 + pub async fn fetch_blob_to_temp_file( 40 + did: String, cid: String, extension: Option<String>, app: AppHandle, 41 + ) -> Result<explorer::TempBlobFile, AppError> { 42 + explorer::fetch_blob_to_temp_file(did, cid, extension, &app).await 43 + } 44 + 45 + #[tauri::command] 46 + pub fn delete_blob_temp_file(path: String, app: AppHandle) -> Result<(), AppError> { 47 + explorer::delete_blob_temp_file(&path, &app) 48 + } 49 + 50 + #[tauri::command] 39 51 pub async fn query_labels(uri: String) -> Result<serde_json::Value, AppError> { 40 52 explorer::query_labels(uri).await 41 53 }
+3 -3
src-tauri/src/commands/media.rs
··· 17 17 18 18 #[tauri::command] 19 19 pub async fn download_image( 20 - url: String, filename: Option<String>, state: State<'_, AppState>, 20 + url: String, filename: Option<String>, app: AppHandle, state: State<'_, AppState>, 21 21 ) -> Result<DownloadResult> { 22 - media::download_image(&url, filename.as_deref(), &state).await 22 + media::download_image(&url, filename.as_deref(), &app, &state).await 23 23 } 24 24 25 25 #[tauri::command] 26 26 pub async fn download_video( 27 27 url: String, filename: Option<String>, app: AppHandle, state: State<'_, AppState>, 28 28 ) -> Result<DownloadResult> { 29 - media::download_video(&url, filename.as_deref(), &state, |progress| { 29 + media::download_video(&url, filename.as_deref(), &app, &state, |progress| { 30 30 app.emit("download-progress", &progress)?; 31 31 Ok(()) 32 32 })
+177 -4
src-tauri/src/explorer.rs
··· 6 6 use jacquard::api::com_atproto::repo::get_record::GetRecord; 7 7 use jacquard::api::com_atproto::repo::list_records::ListRecords; 8 8 use jacquard::api::com_atproto::server::describe_server::DescribeServer; 9 + use jacquard::api::com_atproto::sync::get_blob::GetBlob; 9 10 use jacquard::api::com_atproto::sync::get_repo::GetRepo; 10 11 use jacquard::api::com_atproto::sync::list_repos::ListRepos; 11 12 use jacquard::client::{Agent, UnauthenticatedSession}; 12 13 use jacquard::deps::fluent_uri::Uri; 13 14 use jacquard::identity::{resolver::IdentityResolver, JacquardResolver}; 14 15 use jacquard::types::aturi::AtUri; 16 + use jacquard::types::cid::Cid; 15 17 use jacquard::types::did::Did; 16 18 use jacquard::types::did_doc::DidDocument; 17 19 use jacquard::types::handle::Handle; ··· 27 29 use std::time::Duration; 28 30 use tauri::{AppHandle, Emitter, Manager}; 29 31 use tauri_plugin_log::log; 32 + use uuid::Uuid; 30 33 31 34 pub const EXPLORER_NAVIGATION_EVENT: &str = "navigation:explorer-resolved"; 32 35 const PDS_REPO_LIST_LIMIT: i64 = 100; ··· 98 101 #[serde(rename_all = "camelCase")] 99 102 pub struct RepoCarExport { 100 103 pub did: String, 104 + pub path: String, 105 + pub bytes_written: usize, 106 + } 107 + 108 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 109 + #[serde(rename_all = "camelCase")] 110 + pub struct TempBlobFile { 101 111 pub path: String, 102 112 pub bytes_written: usize, 103 113 } ··· 231 241 path: export_path.to_string_lossy().into_owned(), 232 242 bytes_written: output.body.len(), 233 243 }) 244 + } 245 + 246 + pub async fn fetch_blob_to_temp_file( 247 + did: String, cid: String, extension: Option<String>, app: &AppHandle, 248 + ) -> Result<TempBlobFile> { 249 + let parsed_did = Did::new(did.trim())?.into_static(); 250 + let parsed_cid = parse_cid(&cid)?; 251 + let client = client_for_repo_did(parsed_did.as_str()).await?; 252 + let output = client 253 + .send(GetBlob::new().did(parsed_did.clone()).cid(parsed_cid.clone()).build()) 254 + .await 255 + .map_err(|error| AppError::validation(format!("getBlob request failed: {error}")))? 256 + .into_output() 257 + .map_err(|error| AppError::validation(format!("getBlob output failed: {error}")))?; 258 + 259 + let blob_path = resolve_blob_temp_path(app, parsed_did.as_str(), parsed_cid.as_str(), extension.as_deref())?; 260 + if let Some(parent) = blob_path.parent() { 261 + std::fs::create_dir_all(parent)?; 262 + } 263 + 264 + std::fs::write(&blob_path, &output.body).map_err(|error| { 265 + log::error!( 266 + "failed to write temporary blob file {} for did {} cid {}: {error}", 267 + blob_path.display(), 268 + parsed_did, 269 + parsed_cid 270 + ); 271 + AppError::validation("Couldn't save a temporary media file for playback.") 272 + })?; 273 + 274 + Ok(TempBlobFile { path: blob_path.to_string_lossy().into_owned(), bytes_written: output.body.len() }) 275 + } 276 + 277 + pub fn delete_blob_temp_file(path: &str, app: &AppHandle) -> Result<()> { 278 + let trimmed_path = path.trim(); 279 + if trimmed_path.is_empty() { 280 + return Ok(()); 281 + } 282 + 283 + let target_path = PathBuf::from(trimmed_path); 284 + if !target_path.exists() { 285 + return Ok(()); 286 + } 287 + 288 + let blob_dir = resolve_blob_temp_dir(app)?; 289 + if !blob_dir.exists() { 290 + std::fs::create_dir_all(&blob_dir)?; 291 + } 292 + 293 + let canonical_blob_dir = std::fs::canonicalize(&blob_dir)?; 294 + let canonical_target = std::fs::canonicalize(&target_path).map_err(|error| { 295 + log::warn!( 296 + "failed to resolve blob temp file path {}: {error}", 297 + target_path.display() 298 + ); 299 + AppError::validation("Couldn't remove the temporary media file.") 300 + })?; 301 + 302 + if !is_path_within_directory(&canonical_target, &canonical_blob_dir) { 303 + log::warn!( 304 + "refusing to delete temp blob outside managed directory: {} not in {}", 305 + canonical_target.display(), 306 + canonical_blob_dir.display() 307 + ); 308 + return Err(AppError::validation("Couldn't remove the temporary media file.")); 309 + } 310 + 311 + if canonical_target.is_file() { 312 + std::fs::remove_file(&canonical_target).map_err(|error| { 313 + log::warn!( 314 + "failed to remove temporary blob file {}: {error}", 315 + canonical_target.display() 316 + ); 317 + AppError::validation("Couldn't remove the temporary media file.") 318 + })?; 319 + } 320 + 321 + Ok(()) 234 322 } 235 323 236 324 pub async fn query_labels(uri: String) -> Result<Value> { ··· 551 639 .map_err(AppError::from) 552 640 } 553 641 642 + fn parse_cid(cid: &str) -> Result<Cid<'static>> { 643 + let trimmed = cid.trim(); 644 + if trimmed.is_empty() { 645 + return Err(AppError::validation("CID cannot be empty")); 646 + } 647 + 648 + let parsed = Cid::str(trimmed).into_static(); 649 + parsed 650 + .to_ipld() 651 + .map_err(|error| AppError::validation(format!("invalid CID: {error}")))?; 652 + Ok(parsed) 653 + } 654 + 554 655 fn resolve_car_export_path(app: &AppHandle, did: &str) -> Result<PathBuf> { 555 656 let mut app_data_dir = app 556 657 .path() ··· 569 670 cache_dir.push("explorer"); 570 671 cache_dir.push("favicons"); 571 672 Ok(cache_dir) 673 + } 674 + 675 + fn resolve_blob_temp_dir(app: &AppHandle) -> Result<PathBuf> { 676 + let mut cache_dir = app 677 + .path() 678 + .app_cache_dir() 679 + .map_err(|error| AppError::PathResolve(error.to_string()))?; 680 + cache_dir.push("explorer"); 681 + cache_dir.push("temp-blob"); 682 + Ok(cache_dir) 683 + } 684 + 685 + fn resolve_blob_temp_path(app: &AppHandle, did: &str, cid: &str, extension: Option<&str>) -> Result<PathBuf> { 686 + let mut cache_dir = resolve_blob_temp_dir(app)?; 687 + let safe_extension = sanitize_blob_extension(extension).unwrap_or_else(|| "bin".to_string()); 688 + let file_name = format!( 689 + "{}_{}_{}.{}", 690 + sanitize_did_for_filename(did), 691 + sanitize_cid_for_filename(cid), 692 + Uuid::new_v4(), 693 + safe_extension 694 + ); 695 + cache_dir.push(file_name); 696 + Ok(cache_dir) 697 + } 698 + 699 + fn sanitize_cid_for_filename(cid: &str) -> String { 700 + cid.chars() 701 + .map(|character| match character { 702 + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => character, 703 + _ => '_', 704 + }) 705 + .collect() 706 + } 707 + 708 + fn sanitize_blob_extension(extension: Option<&str>) -> Option<String> { 709 + let normalized = extension 710 + .map(str::trim) 711 + .filter(|value| !value.is_empty()) 712 + .map(|value| value.trim_start_matches('.').to_ascii_lowercase())?; 713 + if normalized.is_empty() || normalized.len() > 12 { 714 + return None; 715 + } 716 + if normalized.chars().all(|character| character.is_ascii_alphanumeric()) { 717 + Some(normalized) 718 + } else { 719 + None 720 + } 721 + } 722 + 723 + fn is_path_within_directory(path: &std::path::Path, directory: &std::path::Path) -> bool { 724 + path.starts_with(directory) 572 725 } 573 726 574 727 fn clear_favicon_cache_dir(cache_dir: &std::path::Path) -> Result<()> { ··· 959 1112 mod tests { 960 1113 use super::{ 961 1114 build_resolved_at_uri, canonical_at_uri, clear_favicon_cache_dir, detect_favicon_mime, detect_input_kind, 962 - extract_favicon_urls, extract_html_attribute, lexicon_favicon_hosts, normalize_handle, normalize_pds_url, 963 - read_cached_favicon_data_url, rel_indicates_favicon, repo_car_filename, repo_metadata_from_did_doc, 964 - resolve_html_base_url, resolve_lexicon_favicon_data_url, sanitize_did_for_filename, write_cached_favicon, 965 - CachedFavicon, ExplorerInputKind, ExplorerTargetKind, 1115 + extract_favicon_urls, extract_html_attribute, is_path_within_directory, lexicon_favicon_hosts, 1116 + normalize_handle, normalize_pds_url, read_cached_favicon_data_url, rel_indicates_favicon, repo_car_filename, 1117 + repo_metadata_from_did_doc, resolve_html_base_url, resolve_lexicon_favicon_data_url, sanitize_blob_extension, 1118 + sanitize_cid_for_filename, sanitize_did_for_filename, write_cached_favicon, CachedFavicon, ExplorerInputKind, 1119 + ExplorerTargetKind, 966 1120 }; 967 1121 use jacquard::types::aturi::AtUri; 968 1122 use jacquard::types::did_doc::DidDocument; ··· 1047 1201 fn repo_car_filenames_are_filesystem_safe() { 1048 1202 assert_eq!(sanitize_did_for_filename("did:plc:alice-123"), "did_plc_alice-123"); 1049 1203 assert_eq!(repo_car_filename("did:plc:alice-123"), "did_plc_alice-123.car"); 1204 + } 1205 + 1206 + #[test] 1207 + fn sanitizes_blob_filename_inputs() { 1208 + assert_eq!(sanitize_cid_for_filename("bafy/beih?123"), "bafy_beih_123"); 1209 + assert_eq!(sanitize_blob_extension(Some(".mp4")), Some("mp4".to_string())); 1210 + assert_eq!(sanitize_blob_extension(Some("webm")), Some("webm".to_string())); 1211 + assert_eq!(sanitize_blob_extension(Some("m3u8?foo")), None); 1212 + assert_eq!(sanitize_blob_extension(Some(" ")), None); 1213 + } 1214 + 1215 + #[test] 1216 + fn verifies_path_containment() { 1217 + let base = std::path::Path::new("/tmp/base"); 1218 + let nested = std::path::Path::new("/tmp/base/nested/file.bin"); 1219 + let outside = std::path::Path::new("/tmp/other/file.bin"); 1220 + 1221 + assert!(is_path_within_directory(nested, base)); 1222 + assert!(!is_path_within_directory(outside, base)); 1050 1223 } 1051 1224 1052 1225 #[test]
+2
src-tauri/src/lib.rs
··· 117 117 cmd::explorer::list_records, 118 118 cmd::explorer::get_record, 119 119 cmd::explorer::export_repo_car, 120 + cmd::explorer::fetch_blob_to_temp_file, 121 + cmd::explorer::delete_blob_temp_file, 120 122 cmd::explorer::query_labels, 121 123 cmd::explorer::get_lexicon_favicons, 122 124 cmd::explorer::clear_lexicon_favicon_cache,
+266 -534
src-tauri/src/media.rs
··· 1 1 use super::error::{AppError, Result}; 2 + use super::explorer; 2 3 use super::settings::SettingsKey; 3 4 use super::state::AppState; 5 + use image::ImageFormat; 6 + use jacquard::types::cid::Cid; 7 + use jacquard::types::did::Did; 4 8 use reqwest::Url; 5 9 use rusqlite::{params, Connection, OptionalExtension}; 6 10 use serde::Serialize; 7 - use std::collections::HashMap; 8 11 use std::fs::{self, OpenOptions}; 9 - use std::io::Write; 12 + use std::io::{Cursor, Write}; 10 13 use std::path::{Path, PathBuf}; 11 - use std::time::Duration; 14 + use tauri::AppHandle; 12 15 use tauri_plugin_log::log; 13 16 use uuid::Uuid; 14 - 15 - const DOWNLOAD_HTTP_TIMEOUT: Duration = Duration::from_secs(45); 16 17 17 18 #[derive(Debug, Clone, Serialize)] 18 19 #[serde(rename_all = "camelCase")] ··· 32 33 pub complete: bool, 33 34 } 34 35 35 - #[derive(Debug, Clone)] 36 - struct VariantPlaylist { 37 - uri: Url, 38 - bandwidth: u64, 39 - } 40 - 41 - #[derive(Debug, Clone)] 42 - struct MediaPlaylist { 43 - init_segment: Option<Url>, 44 - segments: Vec<Url>, 36 + #[derive(Debug, Clone, PartialEq, Eq)] 37 + struct BlobRef { 38 + did: String, 39 + cid: String, 45 40 } 46 41 47 42 pub fn get_download_directory(state: &AppState) -> Result<String> { ··· 55 50 db_set_download_directory(&conn, path) 56 51 } 57 52 58 - pub async fn download_image(url: &str, filename: Option<&str>, state: &AppState) -> Result<DownloadResult> { 53 + pub async fn download_image( 54 + url: &str, filename: Option<&str>, app: &AppHandle, state: &AppState, 55 + ) -> Result<DownloadResult> { 59 56 let download_directory = { 60 57 let conn = state.auth_store.lock_connection()?; 61 58 db_get_download_directory(&conn)? 62 59 }; 63 60 64 - download_image_to_directory(url, filename, &download_directory).await 61 + download_image_to_directory(url, filename, app, &download_directory).await 65 62 } 66 63 67 64 pub async fn download_video<F>( 68 - url: &str, filename: Option<&str>, state: &AppState, mut emitter: F, 65 + url: &str, filename: Option<&str>, app: &AppHandle, state: &AppState, mut emitter: F, 69 66 ) -> Result<DownloadResult> 70 67 where 71 68 F: FnMut(DownloadProgress) -> Result<()>, ··· 75 72 db_get_download_directory(&conn)? 76 73 }; 77 74 78 - download_video_to_directory(url, filename, &download_directory, &mut emitter).await 75 + download_video_to_directory(url, filename, app, &download_directory, &mut emitter).await 79 76 } 80 77 81 78 fn db_get_download_directory(conn: &Connection) -> Result<PathBuf> { ··· 108 105 } 109 106 110 107 async fn download_image_to_directory( 111 - url: &str, filename: Option<&str>, download_directory: &Path, 108 + url: &str, filename: Option<&str>, app: &AppHandle, download_directory: &Path, 112 109 ) -> Result<DownloadResult> { 113 110 ensure_directory_is_writable(download_directory)?; 114 - 115 111 let source_url = parse_http_url(url)?; 116 - let client = reqwest::Client::builder() 117 - .timeout(DOWNLOAD_HTTP_TIMEOUT) 118 - .build() 119 - .map_err(|error| { 120 - log::error!("failed to construct HTTP client for image download: {error}"); 121 - AppError::validation("Couldn't start the image download.") 122 - })?; 123 - 124 - let response = client.get(source_url.clone()).send().await.map_err(|error| { 125 - log::error!("image download request failed for {source_url}: {error}"); 126 - AppError::validation("Couldn't download the image right now.") 127 - })?; 128 - 129 - if !response.status().is_success() { 130 - log::warn!( 131 - "image download request returned non-success status {} for {}", 132 - response.status(), 112 + let temp_blob = fetch_blob_to_temp_file(&source_url, app, Some("blob")).await?; 113 + let bytes = fs::read(&temp_blob.path).map_err(|error| { 114 + log::error!( 115 + "failed to read temporary blob file {} for image download {}: {error}", 116 + temp_blob.path, 133 117 source_url 134 118 ); 135 - return Err(AppError::validation( 136 - "Couldn't download the image because the server rejected the request.", 137 - )); 138 - } 139 - 140 - let content_type = response 141 - .headers() 142 - .get(reqwest::header::CONTENT_TYPE) 143 - .and_then(|header| header.to_str().ok()) 144 - .map(str::to_string); 145 - let bytes = response.bytes().await.map_err(|error| { 146 - log::error!("failed to read image response body for {source_url}: {error}"); 147 119 AppError::validation("Couldn't read the downloaded image data.") 148 120 })?; 121 + cleanup_blob_temp_file(&temp_blob.path, app); 149 122 150 - let default_extension = content_type 151 - .as_deref() 152 - .and_then(extension_from_image_content_type) 153 - .unwrap_or("jpg"); 154 - let output_name = build_filename(&source_url, filename, "image", Some(default_extension)); 123 + let content_type = content_type_from_url(&source_url); 124 + let png_bytes = transcode_image_to_png(&bytes, &source_url, content_type.as_deref())?; 125 + let output_name = build_filename(&source_url, filename, "image", Some("png"), true); 155 126 let output_path = resolve_unique_path(download_directory, &output_name); 156 127 157 - fs::write(&output_path, &bytes).map_err(|error| { 128 + fs::write(&output_path, &png_bytes).map_err(|error| { 158 129 log::error!("failed to write image download to {}: {error}", output_path.display()); 159 130 AppError::validation("Couldn't save the image. Check that your download folder exists and is writable.") 160 131 })?; 161 132 162 - Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: bytes.len() as u64 }) 133 + Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: png_bytes.len() as u64 }) 163 134 } 164 135 165 136 async fn download_video_to_directory<F>( 166 - url: &str, filename: Option<&str>, download_directory: &Path, emit_progress: &mut F, 137 + url: &str, filename: Option<&str>, app: &AppHandle, download_directory: &Path, emit_progress: &mut F, 167 138 ) -> Result<DownloadResult> 168 139 where 169 140 F: FnMut(DownloadProgress) -> Result<()>, ··· 171 142 ensure_directory_is_writable(download_directory)?; 172 143 173 144 let source_url = parse_http_url(url)?; 174 - let client = reqwest::Client::builder() 175 - .timeout(DOWNLOAD_HTTP_TIMEOUT) 176 - .build() 177 - .map_err(|error| { 178 - log::error!("failed to construct HTTP client for video download: {error}"); 179 - AppError::validation("Couldn't start the video download.") 180 - })?; 181 - 182 - let manifest = fetch_text(&client, &source_url, "video playlist").await?; 183 - let variants = parse_master_variants(&source_url, &manifest)?; 184 - let (playlist_url, media_playlist_body) = 185 - if let Some(variant) = variants.iter().max_by_key(|variant| variant.bandwidth) { 186 - let body = fetch_text(&client, &variant.uri, "video variant playlist").await?; 187 - (variant.uri.clone(), body) 188 - } else { 189 - (source_url.clone(), manifest) 190 - }; 191 - 192 - let playlist = parse_media_playlist(&playlist_url, &media_playlist_body)?; 193 - 194 - let output_name = build_filename(&playlist_url, filename, "video", Some("mp4")); 145 + let output_name = build_filename(&source_url, filename, "video", Some("mp4"), true); 195 146 let output_path = resolve_unique_path(download_directory, &output_name); 196 - let mut output_file = OpenOptions::new() 197 - .write(true) 198 - .create_new(true) 199 - .open(&output_path) 200 - .map_err(|error| { 201 - log::error!("failed to create output video file {}: {error}", output_path.display()); 202 - AppError::validation("Couldn't create a file in your download folder.") 203 - })?; 204 - 205 - let mut downloaded_bytes: u64 = 0; 206 - let total_segments = playlist.segments.len(); 147 + let total_segments = 1; 207 148 208 149 maybe_emit_progress( 209 150 emit_progress, 210 151 DownloadProgress { 211 152 url: source_url.to_string(), 212 153 path: output_path.to_string_lossy().into_owned(), 213 - downloaded_bytes, 154 + downloaded_bytes: 0, 214 155 downloaded_segments: 0, 215 156 total_segments, 216 157 complete: false, 217 158 }, 218 159 ); 219 160 220 - let write_result = async { 221 - if let Some(init_segment_url) = &playlist.init_segment { 222 - let init_bytes = fetch_binary(&client, init_segment_url, "video init segment").await?; 223 - output_file.write_all(&init_bytes).map_err(|error| { 224 - log::error!( 225 - "failed to write video init segment to {}: {error}", 161 + let temp_blob = match fetch_blob_to_temp_file(&source_url, app, Some("mp4")).await { 162 + Ok(blob) => blob, 163 + Err(error) => { 164 + if let Err(cleanup_error) = fs::remove_file(&output_path) { 165 + log::warn!( 166 + "failed to delete partial video download {}: {cleanup_error}", 226 167 output_path.display() 227 168 ); 228 - AppError::validation("Couldn't write the video to disk.") 229 - })?; 230 - downloaded_bytes += init_bytes.len() as u64; 169 + } 170 + return Err(error); 231 171 } 232 - 233 - for (index, segment_url) in playlist.segments.iter().enumerate() { 234 - let segment = fetch_binary(&client, segment_url, "video segment").await?; 235 - output_file.write_all(&segment).map_err(|error| { 236 - log::error!( 237 - "failed to write video segment {} to {}: {error}", 238 - segment_url, 172 + }; 173 + let copied_bytes = match fs::copy(&temp_blob.path, &output_path) { 174 + Ok(bytes) => bytes, 175 + Err(error) => { 176 + cleanup_blob_temp_file(&temp_blob.path, app); 177 + if let Err(cleanup_error) = fs::remove_file(&output_path) { 178 + log::warn!( 179 + "failed to delete partial video download {}: {cleanup_error}", 239 180 output_path.display() 240 181 ); 241 - AppError::validation("Couldn't write the video to disk.") 242 - })?; 243 - downloaded_bytes += segment.len() as u64; 244 - 245 - maybe_emit_progress( 246 - emit_progress, 247 - DownloadProgress { 248 - url: source_url.to_string(), 249 - path: output_path.to_string_lossy().into_owned(), 250 - downloaded_bytes, 251 - downloaded_segments: index + 1, 252 - total_segments, 253 - complete: false, 254 - }, 255 - ); 256 - } 257 - 258 - output_file.flush().map_err(|error| { 259 - log::error!("failed to flush output video file {}: {error}", output_path.display()); 260 - AppError::validation("Couldn't finish writing the video to disk.") 261 - })?; 262 - 263 - Ok::<(), AppError>(()) 264 - } 265 - .await; 266 - 267 - if let Err(error) = write_result { 268 - if let Err(cleanup_error) = fs::remove_file(&output_path) { 269 - log::warn!( 270 - "failed to delete partial video download {}: {cleanup_error}", 182 + } 183 + log::error!( 184 + "failed to copy temporary blob {} to download output {}: {error}", 185 + temp_blob.path, 271 186 output_path.display() 272 187 ); 188 + return Err(AppError::validation( 189 + "Couldn't save the video. Check that your download folder exists and is writable.", 190 + )); 273 191 } 274 - return Err(error); 275 - } 192 + }; 193 + cleanup_blob_temp_file(&temp_blob.path, app); 276 194 277 195 maybe_emit_progress( 278 196 emit_progress, 279 197 DownloadProgress { 280 198 url: source_url.to_string(), 281 199 path: output_path.to_string_lossy().into_owned(), 282 - downloaded_bytes, 200 + downloaded_bytes: copied_bytes, 283 201 downloaded_segments: total_segments, 284 202 total_segments, 285 203 complete: true, 286 204 }, 287 205 ); 288 206 289 - Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: downloaded_bytes }) 207 + Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: copied_bytes }) 290 208 } 291 209 292 210 fn maybe_emit_progress<F>(emit_progress: &mut F, payload: DownloadProgress) ··· 298 216 } 299 217 } 300 218 301 - async fn fetch_text(client: &reqwest::Client, url: &Url, label: &str) -> Result<String> { 302 - let response = client.get(url.clone()).send().await.map_err(|error| { 303 - log::error!("failed to fetch {label} {url}: {error}"); 304 - AppError::validation("Couldn't download the video playlist.") 305 - })?; 306 - 307 - if !response.status().is_success() { 308 - log::warn!("{label} request for {url} returned status {}", response.status()); 309 - return Err(AppError::validation( 310 - "Couldn't download the video playlist from the server.", 311 - )); 312 - } 313 - 314 - response.text().await.map_err(|error| { 315 - log::error!("failed to read {label} response body for {url}: {error}"); 316 - AppError::validation("Couldn't read the video playlist data.") 317 - }) 318 - } 319 - 320 - async fn fetch_binary(client: &reqwest::Client, url: &Url, label: &str) -> Result<Vec<u8>> { 321 - let response = client.get(url.clone()).send().await.map_err(|error| { 322 - log::error!("failed to fetch {label} {url}: {error}"); 323 - AppError::validation("Couldn't download part of the video.") 324 - })?; 325 - 326 - if !response.status().is_success() { 327 - log::warn!("{label} request for {url} returned status {}", response.status()); 328 - return Err(AppError::validation( 329 - "Couldn't download part of the video from the server.", 330 - )); 331 - } 332 - 333 - response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|error| { 334 - log::error!("failed to read {label} response body for {url}: {error}"); 335 - AppError::validation("Couldn't read part of the downloaded video.") 336 - }) 337 - } 338 - 339 - fn parse_master_variants(base_url: &Url, manifest: &str) -> Result<Vec<VariantPlaylist>> { 340 - let mut variants = Vec::new(); 341 - let mut pending_bandwidth: Option<u64> = None; 342 - 343 - for raw_line in manifest.lines() { 344 - let line = raw_line.trim(); 345 - if line.is_empty() { 346 - continue; 347 - } 348 - 349 - if let Some(attributes) = line.strip_prefix("#EXT-X-STREAM-INF:") { 350 - let parsed = parse_m3u8_attributes(attributes); 351 - let bandwidth = parsed 352 - .get("BANDWIDTH") 353 - .and_then(|value| value.parse::<u64>().ok()) 354 - .unwrap_or(0); 355 - pending_bandwidth = Some(bandwidth); 356 - continue; 357 - } 358 - 359 - if let Some(bandwidth) = pending_bandwidth.take() { 360 - if line.starts_with('#') { 361 - continue; 362 - } 363 - 364 - let uri = resolve_manifest_url(base_url, line)?; 365 - variants.push(VariantPlaylist { uri, bandwidth }); 366 - } 367 - } 368 - 369 - Ok(variants) 370 - } 371 - 372 - fn parse_media_playlist(base_url: &Url, playlist: &str) -> Result<MediaPlaylist> { 373 - let mut init_segment: Option<Url> = None; 374 - let mut segments: Vec<Url> = Vec::new(); 375 - 376 - for raw_line in playlist.lines() { 377 - let line = raw_line.trim(); 378 - if line.is_empty() { 379 - continue; 380 - } 381 - 382 - if let Some(attributes) = line.strip_prefix("#EXT-X-MAP:") { 383 - let parsed = parse_m3u8_attributes(attributes); 384 - if let Some(uri) = parsed.get("URI") { 385 - init_segment = Some(resolve_manifest_url(base_url, uri)?); 386 - } 387 - continue; 388 - } 389 - 390 - if let Some(attributes) = line.strip_prefix("#EXT-X-KEY:") { 391 - let parsed = parse_m3u8_attributes(attributes); 392 - let method = parsed 393 - .get("METHOD") 394 - .map(String::as_str) 395 - .unwrap_or("NONE") 396 - .to_ascii_uppercase(); 397 - if method != "NONE" { 398 - return Err(AppError::validation( 399 - "This video stream is encrypted and can't be downloaded yet.", 400 - )); 401 - } 402 - continue; 403 - } 404 - 405 - if line.starts_with('#') { 406 - continue; 407 - } 408 - 409 - segments.push(resolve_manifest_url(base_url, line)?); 410 - } 411 - 412 - if segments.is_empty() { 413 - return Err(AppError::validation( 414 - "The video playlist did not contain any downloadable segments.", 415 - )); 416 - } 417 - 418 - Ok(MediaPlaylist { init_segment, segments }) 419 - } 420 - 421 - fn parse_m3u8_attributes(raw: &str) -> HashMap<String, String> { 422 - let mut attributes = HashMap::new(); 423 - let mut current = String::new(); 424 - let mut in_quotes = false; 425 - 426 - for character in raw.chars() { 427 - match character { 428 - '"' => { 429 - in_quotes = !in_quotes; 430 - current.push(character); 431 - } 432 - ',' if !in_quotes => { 433 - if let Some((key, value)) = parse_m3u8_attribute_chunk(&current) { 434 - attributes.insert(key, value); 435 - } 436 - current.clear(); 437 - } 438 - _ => current.push(character), 439 - } 440 - } 441 - 442 - if let Some((key, value)) = parse_m3u8_attribute_chunk(&current) { 443 - attributes.insert(key, value); 444 - } 445 - 446 - attributes 447 - } 448 - 449 - fn parse_m3u8_attribute_chunk(chunk: &str) -> Option<(String, String)> { 450 - let (key, value) = chunk.split_once('=')?; 451 - let normalized_key = key.trim().to_ascii_uppercase(); 452 - if normalized_key.is_empty() { 453 - return None; 454 - } 455 - 456 - let normalized_value = value.trim().trim_matches('"').to_string(); 457 - Some((normalized_key, normalized_value)) 458 - } 459 - 460 - fn resolve_manifest_url(base_url: &Url, candidate: &str) -> Result<Url> { 461 - let trimmed = candidate.trim(); 462 - if trimmed.is_empty() { 463 - return Err(AppError::validation( 464 - "The video playlist referenced an empty segment URL.", 465 - )); 466 - } 467 - 468 - let url = Url::parse(trimmed) 469 - .or_else(|_| base_url.join(trimmed)) 470 - .map_err(|error| { 471 - log::error!("failed to resolve manifest URL '{trimmed}' against {base_url}: {error}"); 472 - AppError::validation("The video playlist contained an invalid segment URL.") 473 - })?; 474 - 475 - ensure_http_url(&url)?; 476 - Ok(url) 477 - } 478 - 479 219 fn parse_http_url(raw_url: &str) -> Result<Url> { 480 220 let trimmed = raw_url.trim(); 481 221 if trimmed.is_empty() { ··· 563 303 normalize_and_validate_directory(&path.to_string_lossy()) 564 304 } 565 305 306 + async fn fetch_blob_to_temp_file( 307 + source_url: &Url, app: &AppHandle, extension: Option<&str>, 308 + ) -> Result<explorer::TempBlobFile> { 309 + let blob_ref = blob_ref_from_url(source_url)?; 310 + explorer::fetch_blob_to_temp_file( 311 + blob_ref.did, 312 + blob_ref.cid, 313 + extension.map(|value| value.to_string()), 314 + app, 315 + ) 316 + .await 317 + } 318 + 319 + fn cleanup_blob_temp_file(path: &str, app: &AppHandle) { 320 + if let Err(error) = explorer::delete_blob_temp_file(path, app) { 321 + log::warn!("failed to clean up temporary blob file {}: {error}", path); 322 + } 323 + } 324 + 325 + fn blob_ref_from_url(source_url: &Url) -> Result<BlobRef> { 326 + let segments: Vec<String> = source_url 327 + .path_segments() 328 + .map(|values| { 329 + values 330 + .filter(|value| !value.is_empty()) 331 + .map(decode_known_url_encoding) 332 + .collect() 333 + }) 334 + .unwrap_or_default(); 335 + if segments.is_empty() { 336 + return Err(AppError::validation("The media URL is missing path segments.")); 337 + } 338 + 339 + for (index, segment) in segments.iter().enumerate() { 340 + if Did::new(segment).is_ok() { 341 + if let Some(candidate) = segments.get(index + 1).and_then(|value| normalize_cid_candidate(value)) { 342 + if Cid::str(candidate).to_ipld().is_ok() { 343 + return Ok(BlobRef { did: segment.clone(), cid: candidate.to_string() }); 344 + } 345 + } 346 + } 347 + } 348 + 349 + Err(AppError::validation( 350 + "Couldn't parse a valid DID/CID blob reference from the media URL.", 351 + )) 352 + } 353 + 354 + fn normalize_cid_candidate(segment: &str) -> Option<&str> { 355 + let without_query = segment.split('?').next().unwrap_or(segment); 356 + let without_fragment = without_query.split('#').next().unwrap_or(without_query); 357 + let without_suffix = without_fragment.split('@').next().unwrap_or(without_fragment); 358 + let without_extension = without_suffix.split('.').next().unwrap_or(without_suffix); 359 + let trimmed = without_extension.trim(); 360 + if trimmed.is_empty() { 361 + None 362 + } else { 363 + Some(trimmed) 364 + } 365 + } 366 + 367 + fn decode_known_url_encoding(segment: &str) -> String { 368 + segment.replace("%3A", ":").replace("%3a", ":") 369 + } 370 + 371 + fn content_type_from_url(source_url: &Url) -> Option<String> { 372 + let path = source_url.path().to_ascii_lowercase(); 373 + if path.ends_with(".png") || path.contains("@png") { 374 + return Some("image/png".to_string()); 375 + } 376 + if path.ends_with(".webp") || path.contains("@webp") { 377 + return Some("image/webp".to_string()); 378 + } 379 + if path.ends_with(".gif") || path.contains("@gif") { 380 + return Some("image/gif".to_string()); 381 + } 382 + if path.ends_with(".bmp") || path.contains("@bmp") { 383 + return Some("image/bmp".to_string()); 384 + } 385 + if path.ends_with(".avif") || path.contains("@avif") { 386 + return Some("image/avif".to_string()); 387 + } 388 + if path.ends_with(".svg") || path.contains("@svg") { 389 + return Some("image/svg+xml".to_string()); 390 + } 391 + if path.ends_with(".jpg") || path.ends_with(".jpeg") || path.contains("@jpeg") || path.contains("@jpg") { 392 + return Some("image/jpeg".to_string()); 393 + } 394 + 395 + None 396 + } 397 + 398 + fn transcode_image_to_png(bytes: &[u8], source_url: &Url, content_type: Option<&str>) -> Result<Vec<u8>> { 399 + let normalized_content_type = content_type 400 + .and_then(|value| value.split(';').next()) 401 + .map(str::trim) 402 + .map(str::to_ascii_lowercase); 403 + if normalized_content_type.as_deref() == Some("image/svg+xml") { 404 + return Err(AppError::validation("This image format can't be saved as PNG yet.")); 405 + } 406 + 407 + let decoded = image::load_from_memory(bytes).map_err(|error| { 408 + log::warn!( 409 + "failed to decode downloaded image as raster for {} (content-type: {:?}): {error}", 410 + source_url, 411 + normalized_content_type 412 + ); 413 + AppError::validation("Couldn't decode the downloaded image data.") 414 + })?; 415 + 416 + let mut encoded = Vec::new(); 417 + decoded 418 + .write_to(&mut Cursor::new(&mut encoded), ImageFormat::Png) 419 + .map_err(|error| { 420 + log::error!( 421 + "failed to transcode image download to PNG for {} (content-type: {:?}): {error}", 422 + source_url, 423 + normalized_content_type 424 + ); 425 + AppError::validation("Couldn't save this image as PNG.") 426 + })?; 427 + 428 + Ok(encoded) 429 + } 430 + 566 431 fn expand_tilde(path: &str) -> PathBuf { 567 432 if path == "~" { 568 433 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)); ··· 577 442 PathBuf::from(path) 578 443 } 579 444 580 - fn extension_from_image_content_type(content_type: &str) -> Option<&'static str> { 581 - let normalized = content_type 582 - .split(';') 583 - .next() 584 - .unwrap_or(content_type) 585 - .trim() 586 - .to_ascii_lowercase(); 587 - 588 - match normalized.as_str() { 589 - "image/jpeg" | "image/jpg" => Some("jpg"), 590 - "image/png" => Some("png"), 591 - "image/webp" => Some("webp"), 592 - "image/gif" => Some("gif"), 593 - "image/avif" => Some("avif"), 594 - "image/svg+xml" => Some("svg"), 595 - "image/bmp" => Some("bmp"), 596 - _ => None, 597 - } 598 - } 599 - 600 - fn build_filename(source_url: &Url, requested: Option<&str>, default_stem: &str, default_ext: Option<&str>) -> String { 445 + fn build_filename( 446 + source_url: &Url, requested: Option<&str>, default_stem: &str, default_ext: Option<&str>, force_extension: bool, 447 + ) -> String { 601 448 let requested_name = requested 602 449 .map(str::trim) 603 450 .filter(|name| !name.is_empty()) ··· 616 463 filename = default_stem.to_string(); 617 464 } 618 465 619 - if Path::new(&filename).extension().is_none() { 620 - if let Some(extension) = default_ext.filter(|extension| !extension.is_empty()) { 621 - filename.push('.'); 622 - filename.push_str(extension); 466 + if let Some(extension) = default_ext.filter(|extension| !extension.is_empty()) { 467 + let mut path = PathBuf::from(&filename); 468 + if force_extension || path.extension().is_none() { 469 + path.set_extension(extension.trim_start_matches('.')); 470 + filename = path.to_string_lossy().into_owned(); 623 471 } 624 472 } 625 473 ··· 681 529 #[cfg(test)] 682 530 mod tests { 683 531 use super::*; 684 - use std::collections::HashMap; 685 - use std::io::{Read, Write}; 686 - use std::net::{SocketAddr, TcpListener}; 687 - use std::sync::mpsc; 688 - use std::thread; 689 - 690 - #[derive(Clone)] 691 - struct TestResponse { 692 - status_line: &'static str, 693 - content_type: &'static str, 694 - body: Vec<u8>, 695 - } 696 - 697 - struct TestServer { 698 - address: SocketAddr, 699 - shutdown_tx: mpsc::Sender<()>, 700 - handle: Option<thread::JoinHandle<()>>, 701 - } 702 - 703 - impl TestServer { 704 - fn url(&self, path: &str) -> String { 705 - format!("http://{}{}", self.address, path) 706 - } 707 - } 708 - 709 - impl Drop for TestServer { 710 - fn drop(&mut self) { 711 - let _ = self.shutdown_tx.send(()); 712 - if let Some(handle) = self.handle.take() { 713 - let _ = handle.join(); 714 - } 715 - } 716 - } 717 - 718 - fn start_test_server(routes: HashMap<String, TestResponse>) -> TestServer { 719 - let listener = TcpListener::bind("127.0.0.1:0").expect("test server should bind"); 720 - listener 721 - .set_nonblocking(true) 722 - .expect("test server listener should be nonblocking"); 723 - let address = listener.local_addr().expect("test server should expose local address"); 724 - let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); 725 - 726 - let handle = thread::spawn(move || loop { 727 - if shutdown_rx.try_recv().is_ok() { 728 - break; 729 - } 730 - 731 - match listener.accept() { 732 - Ok((mut stream, _)) => { 733 - let mut buffer = [0_u8; 4096]; 734 - let read = match stream.read(&mut buffer) { 735 - Ok(read) if read > 0 => read, 736 - _ => continue, 737 - }; 738 - 739 - let request_line = String::from_utf8_lossy(&buffer[..read]); 740 - let target = request_line 741 - .lines() 742 - .next() 743 - .and_then(|line| line.split_whitespace().nth(1)) 744 - .unwrap_or("/") 745 - .split('?') 746 - .next() 747 - .unwrap_or("/") 748 - .to_string(); 749 - 750 - let response = routes.get(&target).cloned().unwrap_or(TestResponse { 751 - status_line: "HTTP/1.1 404 Not Found", 752 - content_type: "text/plain", 753 - body: b"not found".to_vec(), 754 - }); 755 - 756 - let headers = format!( 757 - "{}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", 758 - response.status_line, 759 - response.content_type, 760 - response.body.len() 761 - ); 762 - 763 - if stream.write_all(headers.as_bytes()).is_ok() { 764 - let _ = stream.write_all(&response.body); 765 - } 766 - } 767 - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { 768 - thread::sleep(Duration::from_millis(5)); 769 - } 770 - Err(_) => break, 771 - } 772 - }); 773 - 774 - TestServer { address, shutdown_tx, handle: Some(handle) } 775 - } 532 + use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; 533 + use std::io::Cursor; 776 534 777 535 fn settings_db() -> Connection { 778 536 let conn = Connection::open_in_memory().expect("in-memory db should open"); ··· 785 543 let path = std::env::temp_dir().join(format!("lazurite-media-tests-{}", Uuid::new_v4())); 786 544 fs::create_dir_all(&path).expect("temporary directory should be created"); 787 545 path 546 + } 547 + 548 + fn test_image_bytes(format: ImageFormat) -> Vec<u8> { 549 + let mut image = RgbaImage::new(1, 1); 550 + image.put_pixel(0, 0, Rgba([0xFF, 0x66, 0x00, 0xFF])); 551 + 552 + let mut bytes = Vec::new(); 553 + DynamicImage::ImageRgba8(image) 554 + .write_to(&mut Cursor::new(&mut bytes), format) 555 + .expect("test image should encode"); 556 + bytes 557 + } 558 + 559 + fn is_png(bytes: &[u8]) -> bool { 560 + bytes.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n']) 788 561 } 789 562 790 563 #[test] ··· 829 602 } 830 603 831 604 #[test] 832 - fn parse_master_variants_extracts_bandwidth_and_urls() { 833 - let base_url = Url::parse("https://example.com/path/master.m3u8").expect("url should parse"); 834 - let manifest = 835 - "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1280000\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2560000\nhigh.m3u8\n"; 836 - 837 - let variants = parse_master_variants(&base_url, manifest).expect("master playlist should parse"); 605 + fn blob_ref_parses_from_bsky_image_and_video_urls() { 606 + let image_cid = "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; 607 + let image_url = Url::parse(&format!( 608 + "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:alice/{image_cid}@jpeg" 609 + )) 610 + .expect("image url should parse"); 611 + let image_ref = blob_ref_from_url(&image_url).expect("image blob ref should parse"); 612 + assert_eq!(image_ref.did, "did:plc:alice"); 613 + assert_eq!(image_ref.cid, image_cid); 838 614 839 - assert_eq!(variants.len(), 2); 840 - assert_eq!(variants[0].bandwidth, 1_280_000); 841 - assert_eq!(variants[1].bandwidth, 2_560_000); 842 - assert_eq!(variants[1].uri.as_str(), "https://example.com/path/high.m3u8"); 615 + let video_cid = "bafyreic6b7f6qtk2obzmd2i4uj5qvlnxbv5b3pa3y3n6k5s2ucx6ws73mi"; 616 + let video_url = Url::parse(&format!( 617 + "https://video.bsky.app/watch/did:plc:alice/{video_cid}/playlist.m3u8" 618 + )) 619 + .expect("video url should parse"); 620 + let video_ref = blob_ref_from_url(&video_url).expect("video blob ref should parse"); 621 + assert_eq!(video_ref.did, "did:plc:alice"); 622 + assert_eq!(video_ref.cid, video_cid); 843 623 } 844 624 845 625 #[test] 846 - fn parse_media_playlist_extracts_segments_and_init_map() { 847 - let base_url = Url::parse("https://cdn.example.com/video/index.m3u8").expect("url should parse"); 848 - let playlist = "#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:1.0,\nseg-1.ts\n#EXTINF:1.0,\nseg-2.ts\n"; 626 + fn blob_ref_rejects_urls_without_did_cid_pair() { 627 + let bad_url = Url::parse("https://example.com/media/playlist.m3u8").expect("url should parse"); 628 + let error = blob_ref_from_url(&bad_url).expect_err("blob ref parsing should fail"); 629 + assert!(error.to_string().contains("DID/CID")); 630 + } 849 631 850 - let parsed = parse_media_playlist(&base_url, playlist).expect("media playlist should parse"); 632 + #[test] 633 + fn content_type_is_inferred_from_media_url() { 634 + let png_url = Url::parse("https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:alice/bafy@png") 635 + .expect("png url should parse"); 636 + assert_eq!(content_type_from_url(&png_url).as_deref(), Some("image/png")); 851 637 852 - assert_eq!(parsed.segments.len(), 2); 853 - assert_eq!( 854 - parsed.init_segment.as_ref().map(Url::as_str), 855 - Some("https://cdn.example.com/video/init.mp4") 856 - ); 857 - assert_eq!(parsed.segments[0].as_str(), "https://cdn.example.com/video/seg-1.ts"); 638 + let jpeg_url = Url::parse("https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:alice/bafy@jpeg") 639 + .expect("jpeg url should parse"); 640 + assert_eq!(content_type_from_url(&jpeg_url).as_deref(), Some("image/jpeg")); 858 641 } 859 642 860 - #[tokio::test] 861 - async fn download_image_writes_file_to_target_directory() { 862 - let server = start_test_server(HashMap::from([( 863 - "/image.jpg".to_string(), 864 - TestResponse { status_line: "HTTP/1.1 200 OK", content_type: "image/jpeg", body: b"fake-jpeg".to_vec() }, 865 - )])); 866 - let directory = temp_directory(); 643 + #[test] 644 + fn transcode_image_to_png_converts_valid_image_bytes() { 645 + let jpeg = test_image_bytes(ImageFormat::Jpeg); 646 + let source_url = Url::parse("https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:alice/bafy@jpeg") 647 + .expect("url should parse"); 648 + let converted = 649 + transcode_image_to_png(&jpeg, &source_url, Some("image/jpeg")).expect("image transcode should succeed"); 650 + assert!(is_png(&converted)); 651 + } 867 652 868 - let result = download_image_to_directory(&server.url("/image.jpg"), None, &directory) 869 - .await 870 - .expect("image download should succeed"); 871 - 872 - assert_eq!(result.bytes, 9); 873 - assert!(result.path.ends_with("image.jpg")); 653 + #[test] 654 + fn normalize_cid_candidate_strips_suffixes() { 874 655 assert_eq!( 875 - fs::read(result.path).expect("downloaded image should be readable"), 876 - b"fake-jpeg" 656 + normalize_cid_candidate("bafy123@jpeg").expect("candidate should parse"), 657 + "bafy123" 877 658 ); 659 + assert_eq!( 660 + normalize_cid_candidate("bafy123.mp4?x=1").expect("candidate should parse"), 661 + "bafy123" 662 + ); 663 + assert!(normalize_cid_candidate("").is_none()); 878 664 } 879 665 880 - #[tokio::test] 881 - async fn download_video_downloads_highest_bandwidth_variant_and_emits_progress() { 882 - let server = start_test_server(HashMap::from([ 883 - ( 884 - "/master.m3u8".to_string(), 885 - TestResponse { 886 - status_line: "HTTP/1.1 200 OK", 887 - content_type: "application/vnd.apple.mpegurl", 888 - body: b"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=64000\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=128000\nhigh.m3u8\n" 889 - .to_vec(), 890 - }, 891 - ), 892 - ( 893 - "/high.m3u8".to_string(), 894 - TestResponse { 895 - status_line: "HTTP/1.1 200 OK", 896 - content_type: "application/vnd.apple.mpegurl", 897 - body: b"#EXTM3U\n#EXTINF:1.0,\nseg-a.ts\n#EXTINF:1.0,\nseg-b.ts\n".to_vec(), 898 - }, 899 - ), 900 - ( 901 - "/seg-a.ts".to_string(), 902 - TestResponse { 903 - status_line: "HTTP/1.1 200 OK", 904 - content_type: "video/mp2t", 905 - body: b"segment-a".to_vec(), 906 - }, 907 - ), 908 - ( 909 - "/seg-b.ts".to_string(), 910 - TestResponse { 911 - status_line: "HTTP/1.1 200 OK", 912 - content_type: "video/mp2t", 913 - body: b"segment-b".to_vec(), 914 - }, 915 - ), 916 - ])); 917 - let directory = temp_directory(); 918 - let mut progress_events = Vec::new(); 919 - 920 - let result = download_video_to_directory( 921 - &server.url("/master.m3u8"), 922 - Some("clip.mp4"), 923 - &directory, 924 - &mut |progress| { 925 - progress_events.push(progress); 926 - Ok(()) 927 - }, 928 - ) 929 - .await 930 - .expect("video download should succeed"); 931 - 932 - assert!(result.path.ends_with("clip.mp4")); 933 - assert_eq!(result.bytes, (b"segment-a".len() + b"segment-b".len()) as u64); 934 - 935 - let final_progress = progress_events 936 - .last() 937 - .expect("at least one progress event should be emitted"); 938 - assert!(final_progress.complete); 939 - assert_eq!(final_progress.downloaded_segments, 2); 940 - assert_eq!(final_progress.total_segments, 2); 666 + #[test] 667 + fn build_filename_replaces_existing_extension_when_forced() { 668 + let source_url = Url::parse("https://cdn.example.com/path/master.m3u8").expect("url should parse"); 669 + assert_eq!( 670 + build_filename(&source_url, None, "video", Some("mp4"), true), 671 + "master.mp4" 672 + ); 941 673 assert_eq!( 942 - fs::read(result.path).expect("downloaded video should be readable"), 943 - b"segment-asegment-b" 674 + build_filename(&source_url, Some("custom.m3u8"), "video", Some("mp4"), true), 675 + "custom.mp4" 944 676 ); 945 677 } 946 678 }
+5 -2
src/components/feeds/ImageGallery.test.tsx
··· 5 5 const downloadImageMock = vi.hoisted(() => vi.fn()); 6 6 const revealItemInDirMock = vi.hoisted(() => vi.fn()); 7 7 8 - vi.mock("$/lib/api/media", () => ({ downloadImage: downloadImageMock })); 8 + vi.mock("$/lib/api/media", () => ({ MediaController: { downloadImage: downloadImageMock } })); 9 9 vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: revealItemInDirMock })); 10 10 11 11 const GALLERY_IMAGES = [{ alt: "First image", fullsize: "https://cdn.example.com/first.jpg" }, { ··· 53 53 <ImageGallery 54 54 authorHandle="@alice.test" 55 55 authorHref="/profile/alice.test" 56 + downloadFilenameForIndex={(index) => `post-rkey_${index + 1}`} 56 57 images={[...GALLERY_IMAGES]} 57 58 open 58 59 postText="Gallery post" ··· 62 63 63 64 fireEvent.click(screen.getByRole("button", { name: "Download image" })); 64 65 65 - await waitFor(() => expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/first.jpg")); 66 + await waitFor(() => 67 + expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/first.jpg", "post-rkey_1") 68 + ); 66 69 expect(await screen.findByText("Saved gallery.jpg.")).toBeInTheDocument(); 67 70 68 71 fireEvent.click(screen.getByRole("button", { name: "Open in Finder" }));
+6 -2
src/components/feeds/ImageGallery.tsx
··· 1 1 import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 2 2 import { Icon } from "$/components/shared/Icon"; 3 - import { downloadImage } from "$/lib/api/media"; 3 + import { MediaController } from "$/lib/api/media"; 4 4 import { clamp, normalizeError } from "$/lib/utils/text"; 5 5 import { revealItemInDir } from "@tauri-apps/plugin-opener"; 6 6 import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; ··· 12 12 type ImageGalleryProps = { 13 13 authorHandle?: string; 14 14 authorHref?: string; 15 + downloadFilenameForIndex?: (index: number) => string | null | undefined; 15 16 images: GalleryImage[]; 16 17 open: boolean; 17 18 postText?: string; ··· 113 114 114 115 setDownloadPending(true); 115 116 try { 116 - const result = await downloadImage(currentImage); 117 + const requestedFilename = props.downloadFilenameForIndex?.(index())?.trim(); 118 + const result = requestedFilename 119 + ? await MediaController.downloadImage(currentImage, requestedFilename) 120 + : await MediaController.downloadImage(currentImage); 117 121 queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 118 122 } catch (error) { 119 123 queueNotice({ kind: "error", message: toDownloadErrorMessage(error) });
+48 -2
src/components/feeds/PostCard.test.tsx
··· 7 7 const downloadVideoMock = vi.hoisted(() => vi.fn()); 8 8 const listenMock = vi.hoisted(() => vi.fn()); 9 9 10 - vi.mock("$/lib/api/media", () => ({ downloadImage: downloadImageMock, downloadVideo: downloadVideoMock })); 10 + vi.mock( 11 + "$/lib/api/media", 12 + () => ({ MediaController: { downloadImage: downloadImageMock, downloadVideo: downloadVideoMock } }), 13 + ); 11 14 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 12 15 13 16 function createPost() { ··· 199 202 fireEvent.contextMenu(inlineImage); 200 203 fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); 201 204 202 - await waitFor(() => expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image.jpg")); 205 + await waitFor(() => 206 + expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image.jpg", "123") 207 + ); 208 + }); 209 + 210 + it("uses parent post rkey for video downloads", async () => { 211 + downloadVideoMock.mockResolvedValue({ bytes: 200, path: "/tmp/123.mp4" }); 212 + render(() => ( 213 + <PostCard 214 + post={{ 215 + ...createPost(), 216 + embed: { $type: "app.bsky.embed.video#view", playlist: "https://cdn.example.com/video/master.m3u8" }, 217 + }} /> 218 + )); 219 + 220 + fireEvent.click(screen.getByRole("button", { name: "Download video" })); 221 + 222 + await waitFor(() => 223 + expect(downloadVideoMock).toHaveBeenCalledWith("https://cdn.example.com/video/master.m3u8", "123") 224 + ); 225 + }); 226 + 227 + it("uses indexed parent post rkeys for multi-image downloads", async () => { 228 + downloadImageMock.mockResolvedValue({ bytes: 40, path: "/tmp/post-image.jpg" }); 229 + render(() => ( 230 + <PostCard 231 + post={{ 232 + ...createPost(), 233 + embed: { 234 + $type: "app.bsky.embed.images#view", 235 + images: [{ alt: "Inline image one", fullsize: "https://cdn.example.com/post-image-one.jpg" }, { 236 + alt: "Inline image two", 237 + fullsize: "https://cdn.example.com/post-image-two.jpg", 238 + }], 239 + }, 240 + }} /> 241 + )); 242 + 243 + fireEvent.contextMenu(screen.getByAltText("Inline image two")); 244 + fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); 245 + 246 + await waitFor(() => 247 + expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image-two.jpg", "123_2") 248 + ); 203 249 }); 204 250 });
+41 -4
src/components/feeds/PostCard.tsx
··· 5 5 import { Icon } from "$/components/shared/Icon"; 6 6 import { PostRichText } from "$/components/shared/PostRichText"; 7 7 import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 8 - import { downloadImage } from "$/lib/api/media"; 8 + import { MediaController } from "$/lib/api/media"; 9 9 import { 10 10 buildPublicPostUrl, 11 11 formatRelativeTime, ··· 420 420 } 421 421 422 422 function EmbedContent(props: { embed: EmbedView; post: PostView }) { 423 + const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 424 + 423 425 return ( 424 426 <Switch> 425 427 <Match when={props.embed.$type === "app.bsky.embed.images#view"}> ··· 436 438 <VideoEmbed 437 439 alt={(props.embed as { alt?: string }).alt} 438 440 aspectRatio={(props.embed as { aspectRatio?: { height: number; width: number } }).aspectRatio} 441 + downloadFilename={postRkey() ?? undefined} 439 442 playlist={(props.embed as { playlist?: string }).playlist} 440 443 thumbnail={(props.embed as { thumbnail?: string }).thumbnail} /> 441 444 </Match> ··· 476 479 477 480 function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { 478 481 const images = createMemo(() => props.embed.images.slice(0, 4)); 482 + const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 479 483 const [galleryStartIndex, setGalleryStartIndex] = createSignal<number | null>(null); 480 484 const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 481 485 const [menuOpen, setMenuOpen] = createSignal(false); 486 + const [menuImageIndex, setMenuImageIndex] = createSignal<number | null>(null); 482 487 const [menuImageUrl, setMenuImageUrl] = createSignal<string | null>(null); 483 488 const [downloadPending, setDownloadPending] = createSignal(false); 484 489 const [notice, setNotice] = createSignal<MediaNotice | null>(null); ··· 522 527 function closeMenu() { 523 528 setMenuOpen(false); 524 529 setMenuAnchor(null); 530 + setMenuImageIndex(null); 525 531 setMenuImageUrl(null); 526 532 } 527 533 ··· 530 536 setGalleryStartIndex(index); 531 537 } 532 538 533 - function openImageMenu(event: MouseEvent, url: string | undefined) { 539 + function openImageMenu(event: MouseEvent, url: string | undefined, imageIndex: number) { 534 540 event.preventDefault(); 535 541 event.stopPropagation(); 536 542 543 + setMenuImageIndex(imageIndex); 537 544 setMenuImageUrl(url ?? null); 538 545 setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY }); 539 546 setMenuOpen(true); ··· 541 548 542 549 async function downloadFromContextMenu() { 543 550 const url = menuImageUrl(); 551 + const imageIndex = menuImageIndex(); 544 552 if (!url || downloadPending()) { 545 553 return; 546 554 } 547 555 548 556 setDownloadPending(true); 549 557 try { 550 - const result = await downloadImage(url); 558 + const requestedFilename = buildImageFilename(postRkey(), images().length, imageIndex)?.trim(); 559 + const result = await MediaController.downloadImage(url, requestedFilename ?? null); 560 + 551 561 queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 552 562 } catch (error) { 553 563 queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); ··· 565 575 type="button" 566 576 class="overflow-hidden rounded-[1.2rem] border-0 bg-black/30 p-0 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 567 577 onClick={(event) => openGallery(index(), event)} 568 - onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb)}> 578 + onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb, index())}> 569 579 <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} /> 570 580 </button> 571 581 )} ··· 579 589 open={galleryStartIndex() !== null} 580 590 postText={postText()} 581 591 startIndex={galleryStartIndex() ?? 0} 592 + downloadFilenameForIndex={(imageIndex) => buildImageFilename(postRkey(), images().length, imageIndex)} 582 593 onClose={() => setGalleryStartIndex(null)} /> 583 594 584 595 <ContextMenu ··· 629 640 630 641 function isInteractiveTarget(target: EventTarget | null) { 631 642 return target instanceof Element && !!target.closest("a, button, input, textarea, select, [role='menuitem']"); 643 + } 644 + 645 + function postRkeyFromUri(uri: string | null | undefined) { 646 + if (typeof uri !== "string") { 647 + return null; 648 + } 649 + 650 + const trimmed = uri.trim(); 651 + if (!trimmed.startsWith("at://")) { 652 + return null; 653 + } 654 + 655 + const rkey = trimmed.split("/").at(-1)?.trim(); 656 + return rkey || null; 657 + } 658 + 659 + function buildImageFilename(postRkey: string | null, imageCount: number, imageIndex: number | null) { 660 + if (!postRkey) { 661 + return null; 662 + } 663 + 664 + if (imageCount > 1 && imageIndex !== null && imageIndex >= 0) { 665 + return `${postRkey}_${imageIndex + 1}`; 666 + } 667 + 668 + return postRkey; 632 669 } 633 670 634 671 function filenameFromPath(path: string) {
+4 -2
src/components/feeds/VideoEmbed.test.tsx
··· 6 6 const listenMock = vi.hoisted(() => vi.fn()); 7 7 const revealItemInDirMock = vi.hoisted(() => vi.fn()); 8 8 9 - vi.mock("$/lib/api/media", () => ({ downloadVideo: downloadVideoMock })); 9 + vi.mock("$/lib/api/media", () => ({ MediaController: { downloadVideo: downloadVideoMock } })); 10 10 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 11 11 vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: revealItemInDirMock })); 12 12 ··· 46 46 47 47 fireEvent.click(screen.getByRole("button", { name: "Download video" })); 48 48 49 - await waitFor(() => expect(downloadVideoMock).toHaveBeenCalledWith("https://cdn.example.com/video/master.m3u8")); 49 + await waitFor(() => 50 + expect(downloadVideoMock).toHaveBeenCalledWith("https://cdn.example.com/video/master.m3u8", null) 51 + ); 50 52 expect(await screen.findByText("Saved example.mp4.")).toBeInTheDocument(); 51 53 52 54 fireEvent.click(screen.getByRole("button", { name: "Open in Finder" }));
+5 -2
src/components/feeds/VideoEmbed.tsx
··· 1 1 import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 2 2 import { Icon } from "$/components/shared/Icon"; 3 - import { type DownloadProgress, downloadVideo } from "$/lib/api/media"; 3 + import { MediaController } from "$/lib/api/media"; 4 + import type { DownloadProgress } from "$/lib/api/types/media"; 4 5 import { normalizeError } from "$/lib/utils/text"; 5 6 import { listen } from "@tauri-apps/api/event"; 6 7 import { revealItemInDir } from "@tauri-apps/plugin-opener"; ··· 10 11 type VideoEmbedProps = { 11 12 alt?: string; 12 13 aspectRatio?: { height: number; width: number }; 14 + downloadFilename?: string; 13 15 playlist?: string; 14 16 thumbnail?: string; 15 17 }; ··· 149 151 } 150 152 151 153 try { 152 - const result = await downloadVideo(playlist); 154 + const requestedFilename = props.downloadFilename?.trim(); 155 + const result = await MediaController.downloadVideo(playlist, requestedFilename ?? null); 153 156 queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 154 157 } catch (error) { 155 158 queueNotice({ kind: "error", message: toDownloadErrorMessage(error, "Couldn't save the video right now.") });
+4 -4
src/components/settings/SettingsDownloads.tsx
··· 1 - import { getDownloadDirectory, setDownloadDirectory } from "$/lib/api/media"; 1 + import { MediaController } from "$/lib/api/media"; 2 2 import type { AppSettings } from "$/lib/types"; 3 3 import { normalizeError } from "$/lib/utils/text"; 4 4 import { open } from "@tauri-apps/plugin-dialog"; ··· 28 28 29 29 async function refreshDirectory() { 30 30 try { 31 - setDirectory(await getDownloadDirectory()); 31 + setDirectory(await MediaController.getDownloadDirectory()); 32 32 } catch (error) { 33 33 logger.error("failed to load download directory", { keyValues: { error: normalizeError(error) } }); 34 34 queueFeedback({ kind: "error", message: "Couldn't load your download folder." }); ··· 49 49 return; 50 50 } 51 51 52 - await setDownloadDirectory(nextDirectory); 52 + await MediaController.setDownloadDirectory(nextDirectory); 53 53 await refreshDirectory(); 54 54 queueFeedback({ kind: "success", message: "Download folder updated." }); 55 55 } catch (error) { ··· 68 68 setPending(true); 69 69 dismissFeedback(); 70 70 try { 71 - await setDownloadDirectory("~/Downloads"); 71 + await MediaController.setDownloadDirectory("~/Downloads"); 72 72 await refreshDirectory(); 73 73 queueFeedback({ kind: "success", message: "Download folder reset to default." }); 74 74 } catch (error) {
+3 -1
src/components/settings/SettingsPanel.test.tsx
··· 45 45 46 46 vi.mock( 47 47 "$/lib/api/media", 48 - () => ({ getDownloadDirectory: getDownloadDirectoryMock, setDownloadDirectory: setDownloadDirectoryMock }), 48 + () => ({ 49 + MediaController: { getDownloadDirectory: getDownloadDirectoryMock, setDownloadDirectory: setDownloadDirectoryMock }, 50 + }), 49 51 ); 50 52 51 53 vi.mock("@tauri-apps/plugin-dialog", () => ({ open: dialogOpenMock }));
+9 -1
src/lib/api/explorer.ts
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 - import type { ExplorerServerView, RepoCarExport, ResolvedExplorerInput } from "./types/explorer"; 2 + import type { ExplorerServerView, RepoCarExport, ResolvedExplorerInput, TempBlobFile } from "./types/explorer"; 3 3 4 4 export async function resolveInput(input: string): Promise<ResolvedExplorerInput> { 5 5 return invoke("resolve_input", { input }); ··· 23 23 24 24 export async function exportRepoCar(did: string): Promise<RepoCarExport> { 25 25 return invoke("export_repo_car", { did }); 26 + } 27 + 28 + export async function fetchBlobToTempFile(did: string, cid: string, extension?: string | null): Promise<TempBlobFile> { 29 + return invoke("fetch_blob_to_temp_file", { cid, did, extension: extension ?? null }); 30 + } 31 + 32 + export async function deleteBlobTempFile(path: string): Promise<void> { 33 + return invoke("delete_blob_temp_file", { path }); 26 34 } 27 35 28 36 export async function queryLabels(uri: string): Promise<Record<string, unknown>> {
+3 -11
src/lib/api/media.ts
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 - 3 - type DownloadResult = { path: string; bytes: number }; 4 - 5 - export type DownloadProgress = { 6 - url: string; 7 - path: string; 8 - downloadedBytes: number; 9 - downloadedSegments: number; 10 - totalSegments: number; 11 - complete: boolean; 12 - }; 2 + import type { DownloadResult } from "./types/media"; 13 3 14 4 export function getDownloadDirectory() { 15 5 return invoke<string>("get_download_directory"); ··· 26 16 export function downloadVideo(url: string, filename?: string | null) { 27 17 return invoke<DownloadResult>("download_video", { filename: filename ?? null, url }); 28 18 } 19 + 20 + export const MediaController = { getDownloadDirectory, setDownloadDirectory, downloadImage, downloadVideo };
+2
src/lib/api/types/explorer.ts
··· 27 27 }; 28 28 29 29 export type RepoCarExport = { did: string; path: string; bytesWritten: number }; 30 + 31 + export type TempBlobFile = { path: string; bytesWritten: number };
+10
src/lib/api/types/media.ts
··· 1 + export type DownloadResult = { path: string; bytes: number }; 2 + 3 + export type DownloadProgress = { 4 + url: string; 5 + path: string; 6 + downloadedBytes: number; 7 + downloadedSegments: number; 8 + totalSegments: number; 9 + complete: boolean; 10 + };