From 8259cbf0a239cf3bf9acbd080dc9a5b4a1d706b1 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 7 Apr 2026 14:34:53 -0500 Subject: [PATCH] feat: add blob fetching via tempfile and cleanup --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/explorer.rs | 12 + src-tauri/src/commands/media.rs | 6 +- src-tauri/src/explorer.rs | 181 +++- src-tauri/src/lib.rs | 2 + src-tauri/src/media.rs | 802 ++++++------------ src/components/feeds/ImageGallery.test.tsx | 7 +- src/components/feeds/ImageGallery.tsx | 8 +- src/components/feeds/PostCard.test.tsx | 50 +- src/components/feeds/PostCard.tsx | 45 +- src/components/feeds/VideoEmbed.test.tsx | 6 +- src/components/feeds/VideoEmbed.tsx | 7 +- src/components/settings/SettingsDownloads.tsx | 8 +- .../settings/SettingsPanel.test.tsx | 4 +- src/lib/api/explorer.ts | 10 +- src/lib/api/media.ts | 14 +- src/lib/api/types/explorer.ts | 2 + src/lib/api/types/media.ts | 10 + 19 files changed, 603 insertions(+), 573 deletions(-) create mode 100644 src/lib/api/types/media.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f20d167..24fdc5b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3852,6 +3852,7 @@ dependencies = [ "dirs", "fastembed", "hf-hub 0.5.0", + "image", "jacquard", "reqwest 0.12.28", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b9fcac4..f54c992 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ tauri-plugin-notification = "2" thiserror = "2.0.18" uuid = { version = "1", features = ["v4"] } dirs = "6.0.0" +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } # TODO: add this later # [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] diff --git a/src-tauri/src/commands/explorer.rs b/src-tauri/src/commands/explorer.rs index 9f89f82..d99c5e9 100644 --- a/src-tauri/src/commands/explorer.rs +++ b/src-tauri/src/commands/explorer.rs @@ -35,6 +35,18 @@ pub async fn export_repo_car(did: String, app: AppHandle) -> Result, app: AppHandle, +) -> Result { + explorer::fetch_blob_to_temp_file(did, cid, extension, &app).await +} + +#[tauri::command] +pub fn delete_blob_temp_file(path: String, app: AppHandle) -> Result<(), AppError> { + explorer::delete_blob_temp_file(&path, &app) +} + #[tauri::command] pub async fn query_labels(uri: String) -> Result { explorer::query_labels(uri).await diff --git a/src-tauri/src/commands/media.rs b/src-tauri/src/commands/media.rs index 19842e0..65ca6c2 100644 --- a/src-tauri/src/commands/media.rs +++ b/src-tauri/src/commands/media.rs @@ -17,16 +17,16 @@ pub fn set_download_directory(path: String, state: State<'_, AppState>) -> Resul #[tauri::command] pub async fn download_image( - url: String, filename: Option, state: State<'_, AppState>, + url: String, filename: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { - media::download_image(&url, filename.as_deref(), &state).await + media::download_image(&url, filename.as_deref(), &app, &state).await } #[tauri::command] pub async fn download_video( url: String, filename: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { - media::download_video(&url, filename.as_deref(), &state, |progress| { + media::download_video(&url, filename.as_deref(), &app, &state, |progress| { app.emit("download-progress", &progress)?; Ok(()) }) diff --git a/src-tauri/src/explorer.rs b/src-tauri/src/explorer.rs index 82b7559..0e8c8d5 100644 --- a/src-tauri/src/explorer.rs +++ b/src-tauri/src/explorer.rs @@ -6,12 +6,14 @@ use jacquard::api::com_atproto::repo::describe_repo::DescribeRepo; use jacquard::api::com_atproto::repo::get_record::GetRecord; use jacquard::api::com_atproto::repo::list_records::ListRecords; use jacquard::api::com_atproto::server::describe_server::DescribeServer; +use jacquard::api::com_atproto::sync::get_blob::GetBlob; use jacquard::api::com_atproto::sync::get_repo::GetRepo; use jacquard::api::com_atproto::sync::list_repos::ListRepos; use jacquard::client::{Agent, UnauthenticatedSession}; use jacquard::deps::fluent_uri::Uri; use jacquard::identity::{resolver::IdentityResolver, JacquardResolver}; use jacquard::types::aturi::AtUri; +use jacquard::types::cid::Cid; use jacquard::types::did::Did; use jacquard::types::did_doc::DidDocument; use jacquard::types::handle::Handle; @@ -27,6 +29,7 @@ use std::path::PathBuf; use std::time::Duration; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_log::log; +use uuid::Uuid; pub const EXPLORER_NAVIGATION_EVENT: &str = "navigation:explorer-resolved"; const PDS_REPO_LIST_LIMIT: i64 = 100; @@ -102,6 +105,13 @@ pub struct RepoCarExport { pub bytes_written: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TempBlobFile { + pub path: String, + pub bytes_written: usize, +} + pub async fn resolve_input(input: String) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { @@ -233,6 +243,84 @@ pub async fn export_repo_car(did: String, app: &AppHandle) -> Result, app: &AppHandle, +) -> Result { + let parsed_did = Did::new(did.trim())?.into_static(); + let parsed_cid = parse_cid(&cid)?; + let client = client_for_repo_did(parsed_did.as_str()).await?; + let output = client + .send(GetBlob::new().did(parsed_did.clone()).cid(parsed_cid.clone()).build()) + .await + .map_err(|error| AppError::validation(format!("getBlob request failed: {error}")))? + .into_output() + .map_err(|error| AppError::validation(format!("getBlob output failed: {error}")))?; + + let blob_path = resolve_blob_temp_path(app, parsed_did.as_str(), parsed_cid.as_str(), extension.as_deref())?; + if let Some(parent) = blob_path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(&blob_path, &output.body).map_err(|error| { + log::error!( + "failed to write temporary blob file {} for did {} cid {}: {error}", + blob_path.display(), + parsed_did, + parsed_cid + ); + AppError::validation("Couldn't save a temporary media file for playback.") + })?; + + Ok(TempBlobFile { path: blob_path.to_string_lossy().into_owned(), bytes_written: output.body.len() }) +} + +pub fn delete_blob_temp_file(path: &str, app: &AppHandle) -> Result<()> { + let trimmed_path = path.trim(); + if trimmed_path.is_empty() { + return Ok(()); + } + + let target_path = PathBuf::from(trimmed_path); + if !target_path.exists() { + return Ok(()); + } + + let blob_dir = resolve_blob_temp_dir(app)?; + if !blob_dir.exists() { + std::fs::create_dir_all(&blob_dir)?; + } + + let canonical_blob_dir = std::fs::canonicalize(&blob_dir)?; + let canonical_target = std::fs::canonicalize(&target_path).map_err(|error| { + log::warn!( + "failed to resolve blob temp file path {}: {error}", + target_path.display() + ); + AppError::validation("Couldn't remove the temporary media file.") + })?; + + if !is_path_within_directory(&canonical_target, &canonical_blob_dir) { + log::warn!( + "refusing to delete temp blob outside managed directory: {} not in {}", + canonical_target.display(), + canonical_blob_dir.display() + ); + return Err(AppError::validation("Couldn't remove the temporary media file.")); + } + + if canonical_target.is_file() { + std::fs::remove_file(&canonical_target).map_err(|error| { + log::warn!( + "failed to remove temporary blob file {}: {error}", + canonical_target.display() + ); + AppError::validation("Couldn't remove the temporary media file.") + })?; + } + + Ok(()) +} + pub async fn query_labels(uri: String) -> Result { let normalized_uri = normalize_at_uri(&uri)?; let client = public_client(); @@ -551,6 +639,19 @@ fn parse_record_key(rkey: &str) -> Result>> { .map_err(AppError::from) } +fn parse_cid(cid: &str) -> Result> { + let trimmed = cid.trim(); + if trimmed.is_empty() { + return Err(AppError::validation("CID cannot be empty")); + } + + let parsed = Cid::str(trimmed).into_static(); + parsed + .to_ipld() + .map_err(|error| AppError::validation(format!("invalid CID: {error}")))?; + Ok(parsed) +} + fn resolve_car_export_path(app: &AppHandle, did: &str) -> Result { let mut app_data_dir = app .path() @@ -571,6 +672,58 @@ fn resolve_favicon_cache_dir(app: &AppHandle) -> Result { Ok(cache_dir) } +fn resolve_blob_temp_dir(app: &AppHandle) -> Result { + let mut cache_dir = app + .path() + .app_cache_dir() + .map_err(|error| AppError::PathResolve(error.to_string()))?; + cache_dir.push("explorer"); + cache_dir.push("temp-blob"); + Ok(cache_dir) +} + +fn resolve_blob_temp_path(app: &AppHandle, did: &str, cid: &str, extension: Option<&str>) -> Result { + let mut cache_dir = resolve_blob_temp_dir(app)?; + let safe_extension = sanitize_blob_extension(extension).unwrap_or_else(|| "bin".to_string()); + let file_name = format!( + "{}_{}_{}.{}", + sanitize_did_for_filename(did), + sanitize_cid_for_filename(cid), + Uuid::new_v4(), + safe_extension + ); + cache_dir.push(file_name); + Ok(cache_dir) +} + +fn sanitize_cid_for_filename(cid: &str) -> String { + cid.chars() + .map(|character| match character { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => character, + _ => '_', + }) + .collect() +} + +fn sanitize_blob_extension(extension: Option<&str>) -> Option { + let normalized = extension + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('.').to_ascii_lowercase())?; + if normalized.is_empty() || normalized.len() > 12 { + return None; + } + if normalized.chars().all(|character| character.is_ascii_alphanumeric()) { + Some(normalized) + } else { + None + } +} + +fn is_path_within_directory(path: &std::path::Path, directory: &std::path::Path) -> bool { + path.starts_with(directory) +} + fn clear_favicon_cache_dir(cache_dir: &std::path::Path) -> Result<()> { if !cache_dir.exists() { return Ok(()); @@ -959,10 +1112,11 @@ fn sanitize_did_for_filename(did: &str) -> String { mod tests { use super::{ build_resolved_at_uri, canonical_at_uri, clear_favicon_cache_dir, detect_favicon_mime, detect_input_kind, - extract_favicon_urls, extract_html_attribute, lexicon_favicon_hosts, normalize_handle, normalize_pds_url, - read_cached_favicon_data_url, rel_indicates_favicon, repo_car_filename, repo_metadata_from_did_doc, - resolve_html_base_url, resolve_lexicon_favicon_data_url, sanitize_did_for_filename, write_cached_favicon, - CachedFavicon, ExplorerInputKind, ExplorerTargetKind, + extract_favicon_urls, extract_html_attribute, is_path_within_directory, lexicon_favicon_hosts, + normalize_handle, normalize_pds_url, read_cached_favicon_data_url, rel_indicates_favicon, repo_car_filename, + repo_metadata_from_did_doc, resolve_html_base_url, resolve_lexicon_favicon_data_url, sanitize_blob_extension, + sanitize_cid_for_filename, sanitize_did_for_filename, write_cached_favicon, CachedFavicon, ExplorerInputKind, + ExplorerTargetKind, }; use jacquard::types::aturi::AtUri; use jacquard::types::did_doc::DidDocument; @@ -1049,6 +1203,25 @@ mod tests { assert_eq!(repo_car_filename("did:plc:alice-123"), "did_plc_alice-123.car"); } + #[test] + fn sanitizes_blob_filename_inputs() { + assert_eq!(sanitize_cid_for_filename("bafy/beih?123"), "bafy_beih_123"); + assert_eq!(sanitize_blob_extension(Some(".mp4")), Some("mp4".to_string())); + assert_eq!(sanitize_blob_extension(Some("webm")), Some("webm".to_string())); + assert_eq!(sanitize_blob_extension(Some("m3u8?foo")), None); + assert_eq!(sanitize_blob_extension(Some(" ")), None); + } + + #[test] + fn verifies_path_containment() { + let base = std::path::Path::new("/tmp/base"); + let nested = std::path::Path::new("/tmp/base/nested/file.bin"); + let outside = std::path::Path::new("/tmp/other/file.bin"); + + assert!(is_path_within_directory(nested, base)); + assert!(!is_path_within_directory(outside, base)); + } + #[test] fn derives_candidate_hosts_from_lexicon_nsids() { assert_eq!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 555cde5..2ccf0d7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -117,6 +117,8 @@ pub fn run() { cmd::explorer::list_records, cmd::explorer::get_record, cmd::explorer::export_repo_car, + cmd::explorer::fetch_blob_to_temp_file, + cmd::explorer::delete_blob_temp_file, cmd::explorer::query_labels, cmd::explorer::get_lexicon_favicons, cmd::explorer::clear_lexicon_favicon_cache, diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 9d56da8..5a65114 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -1,19 +1,20 @@ use super::error::{AppError, Result}; +use super::explorer; use super::settings::SettingsKey; use super::state::AppState; +use image::ImageFormat; +use jacquard::types::cid::Cid; +use jacquard::types::did::Did; use reqwest::Url; use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; -use std::collections::HashMap; use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; -use std::time::Duration; +use tauri::AppHandle; use tauri_plugin_log::log; use uuid::Uuid; -const DOWNLOAD_HTTP_TIMEOUT: Duration = Duration::from_secs(45); - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DownloadResult { @@ -32,16 +33,10 @@ pub struct DownloadProgress { pub complete: bool, } -#[derive(Debug, Clone)] -struct VariantPlaylist { - uri: Url, - bandwidth: u64, -} - -#[derive(Debug, Clone)] -struct MediaPlaylist { - init_segment: Option, - segments: Vec, +#[derive(Debug, Clone, PartialEq, Eq)] +struct BlobRef { + did: String, + cid: String, } pub fn get_download_directory(state: &AppState) -> Result { @@ -55,17 +50,19 @@ pub fn set_download_directory(path: &str, state: &AppState) -> Result<()> { db_set_download_directory(&conn, path) } -pub async fn download_image(url: &str, filename: Option<&str>, state: &AppState) -> Result { +pub async fn download_image( + url: &str, filename: Option<&str>, app: &AppHandle, state: &AppState, +) -> Result { let download_directory = { let conn = state.auth_store.lock_connection()?; db_get_download_directory(&conn)? }; - download_image_to_directory(url, filename, &download_directory).await + download_image_to_directory(url, filename, app, &download_directory).await } pub async fn download_video( - url: &str, filename: Option<&str>, state: &AppState, mut emitter: F, + url: &str, filename: Option<&str>, app: &AppHandle, state: &AppState, mut emitter: F, ) -> Result where F: FnMut(DownloadProgress) -> Result<()>, @@ -75,7 +72,7 @@ where db_get_download_directory(&conn)? }; - download_video_to_directory(url, filename, &download_directory, &mut emitter).await + download_video_to_directory(url, filename, app, &download_directory, &mut emitter).await } fn db_get_download_directory(conn: &Connection) -> Result { @@ -108,62 +105,36 @@ fn db_set_download_directory(conn: &Connection, path: &str) -> Result<()> { } async fn download_image_to_directory( - url: &str, filename: Option<&str>, download_directory: &Path, + url: &str, filename: Option<&str>, app: &AppHandle, download_directory: &Path, ) -> Result { ensure_directory_is_writable(download_directory)?; - let source_url = parse_http_url(url)?; - let client = reqwest::Client::builder() - .timeout(DOWNLOAD_HTTP_TIMEOUT) - .build() - .map_err(|error| { - log::error!("failed to construct HTTP client for image download: {error}"); - AppError::validation("Couldn't start the image download.") - })?; - - let response = client.get(source_url.clone()).send().await.map_err(|error| { - log::error!("image download request failed for {source_url}: {error}"); - AppError::validation("Couldn't download the image right now.") - })?; - - if !response.status().is_success() { - log::warn!( - "image download request returned non-success status {} for {}", - response.status(), + let temp_blob = fetch_blob_to_temp_file(&source_url, app, Some("blob")).await?; + let bytes = fs::read(&temp_blob.path).map_err(|error| { + log::error!( + "failed to read temporary blob file {} for image download {}: {error}", + temp_blob.path, source_url ); - return Err(AppError::validation( - "Couldn't download the image because the server rejected the request.", - )); - } - - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|header| header.to_str().ok()) - .map(str::to_string); - let bytes = response.bytes().await.map_err(|error| { - log::error!("failed to read image response body for {source_url}: {error}"); AppError::validation("Couldn't read the downloaded image data.") })?; + cleanup_blob_temp_file(&temp_blob.path, app); - let default_extension = content_type - .as_deref() - .and_then(extension_from_image_content_type) - .unwrap_or("jpg"); - let output_name = build_filename(&source_url, filename, "image", Some(default_extension)); + let content_type = content_type_from_url(&source_url); + let png_bytes = transcode_image_to_png(&bytes, &source_url, content_type.as_deref())?; + let output_name = build_filename(&source_url, filename, "image", Some("png"), true); let output_path = resolve_unique_path(download_directory, &output_name); - fs::write(&output_path, &bytes).map_err(|error| { + fs::write(&output_path, &png_bytes).map_err(|error| { log::error!("failed to write image download to {}: {error}", output_path.display()); AppError::validation("Couldn't save the image. Check that your download folder exists and is writable.") })?; - Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: bytes.len() as u64 }) + Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: png_bytes.len() as u64 }) } async fn download_video_to_directory( - url: &str, filename: Option<&str>, download_directory: &Path, emit_progress: &mut F, + url: &str, filename: Option<&str>, app: &AppHandle, download_directory: &Path, emit_progress: &mut F, ) -> Result where F: FnMut(DownloadProgress) -> Result<()>, @@ -171,122 +142,69 @@ where ensure_directory_is_writable(download_directory)?; let source_url = parse_http_url(url)?; - let client = reqwest::Client::builder() - .timeout(DOWNLOAD_HTTP_TIMEOUT) - .build() - .map_err(|error| { - log::error!("failed to construct HTTP client for video download: {error}"); - AppError::validation("Couldn't start the video download.") - })?; - - let manifest = fetch_text(&client, &source_url, "video playlist").await?; - let variants = parse_master_variants(&source_url, &manifest)?; - let (playlist_url, media_playlist_body) = - if let Some(variant) = variants.iter().max_by_key(|variant| variant.bandwidth) { - let body = fetch_text(&client, &variant.uri, "video variant playlist").await?; - (variant.uri.clone(), body) - } else { - (source_url.clone(), manifest) - }; - - let playlist = parse_media_playlist(&playlist_url, &media_playlist_body)?; - - let output_name = build_filename(&playlist_url, filename, "video", Some("mp4")); + let output_name = build_filename(&source_url, filename, "video", Some("mp4"), true); let output_path = resolve_unique_path(download_directory, &output_name); - let mut output_file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&output_path) - .map_err(|error| { - log::error!("failed to create output video file {}: {error}", output_path.display()); - AppError::validation("Couldn't create a file in your download folder.") - })?; - - let mut downloaded_bytes: u64 = 0; - let total_segments = playlist.segments.len(); + let total_segments = 1; maybe_emit_progress( emit_progress, DownloadProgress { url: source_url.to_string(), path: output_path.to_string_lossy().into_owned(), - downloaded_bytes, + downloaded_bytes: 0, downloaded_segments: 0, total_segments, complete: false, }, ); - let write_result = async { - if let Some(init_segment_url) = &playlist.init_segment { - let init_bytes = fetch_binary(&client, init_segment_url, "video init segment").await?; - output_file.write_all(&init_bytes).map_err(|error| { - log::error!( - "failed to write video init segment to {}: {error}", + let temp_blob = match fetch_blob_to_temp_file(&source_url, app, Some("mp4")).await { + Ok(blob) => blob, + Err(error) => { + if let Err(cleanup_error) = fs::remove_file(&output_path) { + log::warn!( + "failed to delete partial video download {}: {cleanup_error}", output_path.display() ); - AppError::validation("Couldn't write the video to disk.") - })?; - downloaded_bytes += init_bytes.len() as u64; + } + return Err(error); } - - for (index, segment_url) in playlist.segments.iter().enumerate() { - let segment = fetch_binary(&client, segment_url, "video segment").await?; - output_file.write_all(&segment).map_err(|error| { - log::error!( - "failed to write video segment {} to {}: {error}", - segment_url, + }; + let copied_bytes = match fs::copy(&temp_blob.path, &output_path) { + Ok(bytes) => bytes, + Err(error) => { + cleanup_blob_temp_file(&temp_blob.path, app); + if let Err(cleanup_error) = fs::remove_file(&output_path) { + log::warn!( + "failed to delete partial video download {}: {cleanup_error}", output_path.display() ); - AppError::validation("Couldn't write the video to disk.") - })?; - downloaded_bytes += segment.len() as u64; - - maybe_emit_progress( - emit_progress, - DownloadProgress { - url: source_url.to_string(), - path: output_path.to_string_lossy().into_owned(), - downloaded_bytes, - downloaded_segments: index + 1, - total_segments, - complete: false, - }, - ); - } - - output_file.flush().map_err(|error| { - log::error!("failed to flush output video file {}: {error}", output_path.display()); - AppError::validation("Couldn't finish writing the video to disk.") - })?; - - Ok::<(), AppError>(()) - } - .await; - - if let Err(error) = write_result { - if let Err(cleanup_error) = fs::remove_file(&output_path) { - log::warn!( - "failed to delete partial video download {}: {cleanup_error}", + } + log::error!( + "failed to copy temporary blob {} to download output {}: {error}", + temp_blob.path, output_path.display() ); + return Err(AppError::validation( + "Couldn't save the video. Check that your download folder exists and is writable.", + )); } - return Err(error); - } + }; + cleanup_blob_temp_file(&temp_blob.path, app); maybe_emit_progress( emit_progress, DownloadProgress { url: source_url.to_string(), path: output_path.to_string_lossy().into_owned(), - downloaded_bytes, + downloaded_bytes: copied_bytes, downloaded_segments: total_segments, total_segments, complete: true, }, ); - Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: downloaded_bytes }) + Ok(DownloadResult { path: output_path.to_string_lossy().into_owned(), bytes: copied_bytes }) } fn maybe_emit_progress(emit_progress: &mut F, payload: DownloadProgress) @@ -298,184 +216,6 @@ where } } -async fn fetch_text(client: &reqwest::Client, url: &Url, label: &str) -> Result { - let response = client.get(url.clone()).send().await.map_err(|error| { - log::error!("failed to fetch {label} {url}: {error}"); - AppError::validation("Couldn't download the video playlist.") - })?; - - if !response.status().is_success() { - log::warn!("{label} request for {url} returned status {}", response.status()); - return Err(AppError::validation( - "Couldn't download the video playlist from the server.", - )); - } - - response.text().await.map_err(|error| { - log::error!("failed to read {label} response body for {url}: {error}"); - AppError::validation("Couldn't read the video playlist data.") - }) -} - -async fn fetch_binary(client: &reqwest::Client, url: &Url, label: &str) -> Result> { - let response = client.get(url.clone()).send().await.map_err(|error| { - log::error!("failed to fetch {label} {url}: {error}"); - AppError::validation("Couldn't download part of the video.") - })?; - - if !response.status().is_success() { - log::warn!("{label} request for {url} returned status {}", response.status()); - return Err(AppError::validation( - "Couldn't download part of the video from the server.", - )); - } - - response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|error| { - log::error!("failed to read {label} response body for {url}: {error}"); - AppError::validation("Couldn't read part of the downloaded video.") - }) -} - -fn parse_master_variants(base_url: &Url, manifest: &str) -> Result> { - let mut variants = Vec::new(); - let mut pending_bandwidth: Option = None; - - for raw_line in manifest.lines() { - let line = raw_line.trim(); - if line.is_empty() { - continue; - } - - if let Some(attributes) = line.strip_prefix("#EXT-X-STREAM-INF:") { - let parsed = parse_m3u8_attributes(attributes); - let bandwidth = parsed - .get("BANDWIDTH") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); - pending_bandwidth = Some(bandwidth); - continue; - } - - if let Some(bandwidth) = pending_bandwidth.take() { - if line.starts_with('#') { - continue; - } - - let uri = resolve_manifest_url(base_url, line)?; - variants.push(VariantPlaylist { uri, bandwidth }); - } - } - - Ok(variants) -} - -fn parse_media_playlist(base_url: &Url, playlist: &str) -> Result { - let mut init_segment: Option = None; - let mut segments: Vec = Vec::new(); - - for raw_line in playlist.lines() { - let line = raw_line.trim(); - if line.is_empty() { - continue; - } - - if let Some(attributes) = line.strip_prefix("#EXT-X-MAP:") { - let parsed = parse_m3u8_attributes(attributes); - if let Some(uri) = parsed.get("URI") { - init_segment = Some(resolve_manifest_url(base_url, uri)?); - } - continue; - } - - if let Some(attributes) = line.strip_prefix("#EXT-X-KEY:") { - let parsed = parse_m3u8_attributes(attributes); - let method = parsed - .get("METHOD") - .map(String::as_str) - .unwrap_or("NONE") - .to_ascii_uppercase(); - if method != "NONE" { - return Err(AppError::validation( - "This video stream is encrypted and can't be downloaded yet.", - )); - } - continue; - } - - if line.starts_with('#') { - continue; - } - - segments.push(resolve_manifest_url(base_url, line)?); - } - - if segments.is_empty() { - return Err(AppError::validation( - "The video playlist did not contain any downloadable segments.", - )); - } - - Ok(MediaPlaylist { init_segment, segments }) -} - -fn parse_m3u8_attributes(raw: &str) -> HashMap { - let mut attributes = HashMap::new(); - let mut current = String::new(); - let mut in_quotes = false; - - for character in raw.chars() { - match character { - '"' => { - in_quotes = !in_quotes; - current.push(character); - } - ',' if !in_quotes => { - if let Some((key, value)) = parse_m3u8_attribute_chunk(¤t) { - attributes.insert(key, value); - } - current.clear(); - } - _ => current.push(character), - } - } - - if let Some((key, value)) = parse_m3u8_attribute_chunk(¤t) { - attributes.insert(key, value); - } - - attributes -} - -fn parse_m3u8_attribute_chunk(chunk: &str) -> Option<(String, String)> { - let (key, value) = chunk.split_once('=')?; - let normalized_key = key.trim().to_ascii_uppercase(); - if normalized_key.is_empty() { - return None; - } - - let normalized_value = value.trim().trim_matches('"').to_string(); - Some((normalized_key, normalized_value)) -} - -fn resolve_manifest_url(base_url: &Url, candidate: &str) -> Result { - let trimmed = candidate.trim(); - if trimmed.is_empty() { - return Err(AppError::validation( - "The video playlist referenced an empty segment URL.", - )); - } - - let url = Url::parse(trimmed) - .or_else(|_| base_url.join(trimmed)) - .map_err(|error| { - log::error!("failed to resolve manifest URL '{trimmed}' against {base_url}: {error}"); - AppError::validation("The video playlist contained an invalid segment URL.") - })?; - - ensure_http_url(&url)?; - Ok(url) -} - fn parse_http_url(raw_url: &str) -> Result { let trimmed = raw_url.trim(); if trimmed.is_empty() { @@ -563,6 +303,131 @@ fn default_download_directory_path() -> Result { normalize_and_validate_directory(&path.to_string_lossy()) } +async fn fetch_blob_to_temp_file( + source_url: &Url, app: &AppHandle, extension: Option<&str>, +) -> Result { + let blob_ref = blob_ref_from_url(source_url)?; + explorer::fetch_blob_to_temp_file( + blob_ref.did, + blob_ref.cid, + extension.map(|value| value.to_string()), + app, + ) + .await +} + +fn cleanup_blob_temp_file(path: &str, app: &AppHandle) { + if let Err(error) = explorer::delete_blob_temp_file(path, app) { + log::warn!("failed to clean up temporary blob file {}: {error}", path); + } +} + +fn blob_ref_from_url(source_url: &Url) -> Result { + let segments: Vec = source_url + .path_segments() + .map(|values| { + values + .filter(|value| !value.is_empty()) + .map(decode_known_url_encoding) + .collect() + }) + .unwrap_or_default(); + if segments.is_empty() { + return Err(AppError::validation("The media URL is missing path segments.")); + } + + for (index, segment) in segments.iter().enumerate() { + if Did::new(segment).is_ok() { + if let Some(candidate) = segments.get(index + 1).and_then(|value| normalize_cid_candidate(value)) { + if Cid::str(candidate).to_ipld().is_ok() { + return Ok(BlobRef { did: segment.clone(), cid: candidate.to_string() }); + } + } + } + } + + Err(AppError::validation( + "Couldn't parse a valid DID/CID blob reference from the media URL.", + )) +} + +fn normalize_cid_candidate(segment: &str) -> Option<&str> { + let without_query = segment.split('?').next().unwrap_or(segment); + let without_fragment = without_query.split('#').next().unwrap_or(without_query); + let without_suffix = without_fragment.split('@').next().unwrap_or(without_fragment); + let without_extension = without_suffix.split('.').next().unwrap_or(without_suffix); + let trimmed = without_extension.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn decode_known_url_encoding(segment: &str) -> String { + segment.replace("%3A", ":").replace("%3a", ":") +} + +fn content_type_from_url(source_url: &Url) -> Option { + let path = source_url.path().to_ascii_lowercase(); + if path.ends_with(".png") || path.contains("@png") { + return Some("image/png".to_string()); + } + if path.ends_with(".webp") || path.contains("@webp") { + return Some("image/webp".to_string()); + } + if path.ends_with(".gif") || path.contains("@gif") { + return Some("image/gif".to_string()); + } + if path.ends_with(".bmp") || path.contains("@bmp") { + return Some("image/bmp".to_string()); + } + if path.ends_with(".avif") || path.contains("@avif") { + return Some("image/avif".to_string()); + } + if path.ends_with(".svg") || path.contains("@svg") { + return Some("image/svg+xml".to_string()); + } + if path.ends_with(".jpg") || path.ends_with(".jpeg") || path.contains("@jpeg") || path.contains("@jpg") { + return Some("image/jpeg".to_string()); + } + + None +} + +fn transcode_image_to_png(bytes: &[u8], source_url: &Url, content_type: Option<&str>) -> Result> { + let normalized_content_type = content_type + .and_then(|value| value.split(';').next()) + .map(str::trim) + .map(str::to_ascii_lowercase); + if normalized_content_type.as_deref() == Some("image/svg+xml") { + return Err(AppError::validation("This image format can't be saved as PNG yet.")); + } + + let decoded = image::load_from_memory(bytes).map_err(|error| { + log::warn!( + "failed to decode downloaded image as raster for {} (content-type: {:?}): {error}", + source_url, + normalized_content_type + ); + AppError::validation("Couldn't decode the downloaded image data.") + })?; + + let mut encoded = Vec::new(); + decoded + .write_to(&mut Cursor::new(&mut encoded), ImageFormat::Png) + .map_err(|error| { + log::error!( + "failed to transcode image download to PNG for {} (content-type: {:?}): {error}", + source_url, + normalized_content_type + ); + AppError::validation("Couldn't save this image as PNG.") + })?; + + Ok(encoded) +} + fn expand_tilde(path: &str) -> PathBuf { if path == "~" { return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)); @@ -577,27 +442,9 @@ fn expand_tilde(path: &str) -> PathBuf { PathBuf::from(path) } -fn extension_from_image_content_type(content_type: &str) -> Option<&'static str> { - let normalized = content_type - .split(';') - .next() - .unwrap_or(content_type) - .trim() - .to_ascii_lowercase(); - - match normalized.as_str() { - "image/jpeg" | "image/jpg" => Some("jpg"), - "image/png" => Some("png"), - "image/webp" => Some("webp"), - "image/gif" => Some("gif"), - "image/avif" => Some("avif"), - "image/svg+xml" => Some("svg"), - "image/bmp" => Some("bmp"), - _ => None, - } -} - -fn build_filename(source_url: &Url, requested: Option<&str>, default_stem: &str, default_ext: Option<&str>) -> String { +fn build_filename( + source_url: &Url, requested: Option<&str>, default_stem: &str, default_ext: Option<&str>, force_extension: bool, +) -> String { let requested_name = requested .map(str::trim) .filter(|name| !name.is_empty()) @@ -616,10 +463,11 @@ fn build_filename(source_url: &Url, requested: Option<&str>, default_stem: &str, filename = default_stem.to_string(); } - if Path::new(&filename).extension().is_none() { - if let Some(extension) = default_ext.filter(|extension| !extension.is_empty()) { - filename.push('.'); - filename.push_str(extension); + if let Some(extension) = default_ext.filter(|extension| !extension.is_empty()) { + let mut path = PathBuf::from(&filename); + if force_extension || path.extension().is_none() { + path.set_extension(extension.trim_start_matches('.')); + filename = path.to_string_lossy().into_owned(); } } @@ -681,98 +529,8 @@ fn resolve_unique_path(directory: &Path, filename: &str) -> PathBuf { #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; - use std::io::{Read, Write}; - use std::net::{SocketAddr, TcpListener}; - use std::sync::mpsc; - use std::thread; - - #[derive(Clone)] - struct TestResponse { - status_line: &'static str, - content_type: &'static str, - body: Vec, - } - - struct TestServer { - address: SocketAddr, - shutdown_tx: mpsc::Sender<()>, - handle: Option>, - } - - impl TestServer { - fn url(&self, path: &str) -> String { - format!("http://{}{}", self.address, path) - } - } - - impl Drop for TestServer { - fn drop(&mut self) { - let _ = self.shutdown_tx.send(()); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); - } - } - } - - fn start_test_server(routes: HashMap) -> TestServer { - let listener = TcpListener::bind("127.0.0.1:0").expect("test server should bind"); - listener - .set_nonblocking(true) - .expect("test server listener should be nonblocking"); - let address = listener.local_addr().expect("test server should expose local address"); - let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); - - let handle = thread::spawn(move || loop { - if shutdown_rx.try_recv().is_ok() { - break; - } - - match listener.accept() { - Ok((mut stream, _)) => { - let mut buffer = [0_u8; 4096]; - let read = match stream.read(&mut buffer) { - Ok(read) if read > 0 => read, - _ => continue, - }; - - let request_line = String::from_utf8_lossy(&buffer[..read]); - let target = request_line - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .unwrap_or("/") - .split('?') - .next() - .unwrap_or("/") - .to_string(); - - let response = routes.get(&target).cloned().unwrap_or(TestResponse { - status_line: "HTTP/1.1 404 Not Found", - content_type: "text/plain", - body: b"not found".to_vec(), - }); - - let headers = format!( - "{}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - response.status_line, - response.content_type, - response.body.len() - ); - - if stream.write_all(headers.as_bytes()).is_ok() { - let _ = stream.write_all(&response.body); - } - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(5)); - } - Err(_) => break, - } - }); - - TestServer { address, shutdown_tx, handle: Some(handle) } - } + use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; + use std::io::Cursor; fn settings_db() -> Connection { let conn = Connection::open_in_memory().expect("in-memory db should open"); @@ -787,6 +545,21 @@ mod tests { path } + fn test_image_bytes(format: ImageFormat) -> Vec { + let mut image = RgbaImage::new(1, 1); + image.put_pixel(0, 0, Rgba([0xFF, 0x66, 0x00, 0xFF])); + + let mut bytes = Vec::new(); + DynamicImage::ImageRgba8(image) + .write_to(&mut Cursor::new(&mut bytes), format) + .expect("test image should encode"); + bytes + } + + fn is_png(bytes: &[u8]) -> bool { + bytes.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n']) + } + #[test] fn set_download_directory_persists_value() { let conn = settings_db(); @@ -829,118 +602,77 @@ mod tests { } #[test] - fn parse_master_variants_extracts_bandwidth_and_urls() { - let base_url = Url::parse("https://example.com/path/master.m3u8").expect("url should parse"); - let manifest = - "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1280000\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2560000\nhigh.m3u8\n"; - - let variants = parse_master_variants(&base_url, manifest).expect("master playlist should parse"); - - assert_eq!(variants.len(), 2); - assert_eq!(variants[0].bandwidth, 1_280_000); - assert_eq!(variants[1].bandwidth, 2_560_000); - assert_eq!(variants[1].uri.as_str(), "https://example.com/path/high.m3u8"); + fn blob_ref_parses_from_bsky_image_and_video_urls() { + let image_cid = "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; + let image_url = Url::parse(&format!( + "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:alice/{image_cid}@jpeg" + )) + .expect("image url should parse"); + let image_ref = blob_ref_from_url(&image_url).expect("image blob ref should parse"); + assert_eq!(image_ref.did, "did:plc:alice"); + assert_eq!(image_ref.cid, image_cid); + + let video_cid = "bafyreic6b7f6qtk2obzmd2i4uj5qvlnxbv5b3pa3y3n6k5s2ucx6ws73mi"; + let video_url = Url::parse(&format!( + "https://video.bsky.app/watch/did:plc:alice/{video_cid}/playlist.m3u8" + )) + .expect("video url should parse"); + let video_ref = blob_ref_from_url(&video_url).expect("video blob ref should parse"); + assert_eq!(video_ref.did, "did:plc:alice"); + assert_eq!(video_ref.cid, video_cid); } #[test] - fn parse_media_playlist_extracts_segments_and_init_map() { - let base_url = Url::parse("https://cdn.example.com/video/index.m3u8").expect("url should parse"); - 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"; + fn blob_ref_rejects_urls_without_did_cid_pair() { + let bad_url = Url::parse("https://example.com/media/playlist.m3u8").expect("url should parse"); + let error = blob_ref_from_url(&bad_url).expect_err("blob ref parsing should fail"); + assert!(error.to_string().contains("DID/CID")); + } - let parsed = parse_media_playlist(&base_url, playlist).expect("media playlist should parse"); + #[test] + fn content_type_is_inferred_from_media_url() { + let png_url = Url::parse("https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:alice/bafy@png") + .expect("png url should parse"); + assert_eq!(content_type_from_url(&png_url).as_deref(), Some("image/png")); - assert_eq!(parsed.segments.len(), 2); - assert_eq!( - parsed.init_segment.as_ref().map(Url::as_str), - Some("https://cdn.example.com/video/init.mp4") - ); - assert_eq!(parsed.segments[0].as_str(), "https://cdn.example.com/video/seg-1.ts"); + let jpeg_url = Url::parse("https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:alice/bafy@jpeg") + .expect("jpeg url should parse"); + assert_eq!(content_type_from_url(&jpeg_url).as_deref(), Some("image/jpeg")); } - #[tokio::test] - async fn download_image_writes_file_to_target_directory() { - let server = start_test_server(HashMap::from([( - "/image.jpg".to_string(), - TestResponse { status_line: "HTTP/1.1 200 OK", content_type: "image/jpeg", body: b"fake-jpeg".to_vec() }, - )])); - let directory = temp_directory(); - - let result = download_image_to_directory(&server.url("/image.jpg"), None, &directory) - .await - .expect("image download should succeed"); + #[test] + fn transcode_image_to_png_converts_valid_image_bytes() { + let jpeg = test_image_bytes(ImageFormat::Jpeg); + let source_url = Url::parse("https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:alice/bafy@jpeg") + .expect("url should parse"); + let converted = + transcode_image_to_png(&jpeg, &source_url, Some("image/jpeg")).expect("image transcode should succeed"); + assert!(is_png(&converted)); + } - assert_eq!(result.bytes, 9); - assert!(result.path.ends_with("image.jpg")); + #[test] + fn normalize_cid_candidate_strips_suffixes() { assert_eq!( - fs::read(result.path).expect("downloaded image should be readable"), - b"fake-jpeg" + normalize_cid_candidate("bafy123@jpeg").expect("candidate should parse"), + "bafy123" ); + assert_eq!( + normalize_cid_candidate("bafy123.mp4?x=1").expect("candidate should parse"), + "bafy123" + ); + assert!(normalize_cid_candidate("").is_none()); } - #[tokio::test] - async fn download_video_downloads_highest_bandwidth_variant_and_emits_progress() { - let server = start_test_server(HashMap::from([ - ( - "/master.m3u8".to_string(), - TestResponse { - status_line: "HTTP/1.1 200 OK", - content_type: "application/vnd.apple.mpegurl", - body: b"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=64000\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=128000\nhigh.m3u8\n" - .to_vec(), - }, - ), - ( - "/high.m3u8".to_string(), - TestResponse { - status_line: "HTTP/1.1 200 OK", - content_type: "application/vnd.apple.mpegurl", - body: b"#EXTM3U\n#EXTINF:1.0,\nseg-a.ts\n#EXTINF:1.0,\nseg-b.ts\n".to_vec(), - }, - ), - ( - "/seg-a.ts".to_string(), - TestResponse { - status_line: "HTTP/1.1 200 OK", - content_type: "video/mp2t", - body: b"segment-a".to_vec(), - }, - ), - ( - "/seg-b.ts".to_string(), - TestResponse { - status_line: "HTTP/1.1 200 OK", - content_type: "video/mp2t", - body: b"segment-b".to_vec(), - }, - ), - ])); - let directory = temp_directory(); - let mut progress_events = Vec::new(); - - let result = download_video_to_directory( - &server.url("/master.m3u8"), - Some("clip.mp4"), - &directory, - &mut |progress| { - progress_events.push(progress); - Ok(()) - }, - ) - .await - .expect("video download should succeed"); - - assert!(result.path.ends_with("clip.mp4")); - assert_eq!(result.bytes, (b"segment-a".len() + b"segment-b".len()) as u64); - - let final_progress = progress_events - .last() - .expect("at least one progress event should be emitted"); - assert!(final_progress.complete); - assert_eq!(final_progress.downloaded_segments, 2); - assert_eq!(final_progress.total_segments, 2); + #[test] + fn build_filename_replaces_existing_extension_when_forced() { + let source_url = Url::parse("https://cdn.example.com/path/master.m3u8").expect("url should parse"); + assert_eq!( + build_filename(&source_url, None, "video", Some("mp4"), true), + "master.mp4" + ); assert_eq!( - fs::read(result.path).expect("downloaded video should be readable"), - b"segment-asegment-b" + build_filename(&source_url, Some("custom.m3u8"), "video", Some("mp4"), true), + "custom.mp4" ); } } diff --git a/src/components/feeds/ImageGallery.test.tsx b/src/components/feeds/ImageGallery.test.tsx index 538d58a..d096f9d 100644 --- a/src/components/feeds/ImageGallery.test.tsx +++ b/src/components/feeds/ImageGallery.test.tsx @@ -5,7 +5,7 @@ import { ImageGallery } from "./ImageGallery"; const downloadImageMock = vi.hoisted(() => vi.fn()); const revealItemInDirMock = vi.hoisted(() => vi.fn()); -vi.mock("$/lib/api/media", () => ({ downloadImage: downloadImageMock })); +vi.mock("$/lib/api/media", () => ({ MediaController: { downloadImage: downloadImageMock } })); vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: revealItemInDirMock })); const GALLERY_IMAGES = [{ alt: "First image", fullsize: "https://cdn.example.com/first.jpg" }, { @@ -53,6 +53,7 @@ describe("ImageGallery", () => { `post-rkey_${index + 1}`} images={[...GALLERY_IMAGES]} open postText="Gallery post" @@ -62,7 +63,9 @@ describe("ImageGallery", () => { fireEvent.click(screen.getByRole("button", { name: "Download image" })); - await waitFor(() => expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/first.jpg")); + await waitFor(() => + expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/first.jpg", "post-rkey_1") + ); expect(await screen.findByText("Saved gallery.jpg.")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Open in Finder" })); diff --git a/src/components/feeds/ImageGallery.tsx b/src/components/feeds/ImageGallery.tsx index a864742..4216c6e 100644 --- a/src/components/feeds/ImageGallery.tsx +++ b/src/components/feeds/ImageGallery.tsx @@ -1,6 +1,6 @@ import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; import { Icon } from "$/components/shared/Icon"; -import { downloadImage } from "$/lib/api/media"; +import { MediaController } from "$/lib/api/media"; import { clamp, normalizeError } from "$/lib/utils/text"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; @@ -12,6 +12,7 @@ type GalleryImage = { alt?: string; fullsize?: string; thumb?: string }; type ImageGalleryProps = { authorHandle?: string; authorHref?: string; + downloadFilenameForIndex?: (index: number) => string | null | undefined; images: GalleryImage[]; open: boolean; postText?: string; @@ -113,7 +114,10 @@ export function ImageGallery(props: ImageGalleryProps) { setDownloadPending(true); try { - const result = await downloadImage(currentImage); + const requestedFilename = props.downloadFilenameForIndex?.(index())?.trim(); + const result = requestedFilename + ? await MediaController.downloadImage(currentImage, requestedFilename) + : await MediaController.downloadImage(currentImage); queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); } catch (error) { queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); diff --git a/src/components/feeds/PostCard.test.tsx b/src/components/feeds/PostCard.test.tsx index adf8201..c4e557f 100644 --- a/src/components/feeds/PostCard.test.tsx +++ b/src/components/feeds/PostCard.test.tsx @@ -7,7 +7,10 @@ const downloadImageMock = vi.hoisted(() => vi.fn()); const downloadVideoMock = vi.hoisted(() => vi.fn()); const listenMock = vi.hoisted(() => vi.fn()); -vi.mock("$/lib/api/media", () => ({ downloadImage: downloadImageMock, downloadVideo: downloadVideoMock })); +vi.mock( + "$/lib/api/media", + () => ({ MediaController: { downloadImage: downloadImageMock, downloadVideo: downloadVideoMock } }), +); vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); function createPost() { @@ -199,6 +202,49 @@ describe("PostCard", () => { fireEvent.contextMenu(inlineImage); fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); - await waitFor(() => expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image.jpg")); + await waitFor(() => + expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image.jpg", "123") + ); + }); + + it("uses parent post rkey for video downloads", async () => { + downloadVideoMock.mockResolvedValue({ bytes: 200, path: "/tmp/123.mp4" }); + render(() => ( + + )); + + fireEvent.click(screen.getByRole("button", { name: "Download video" })); + + await waitFor(() => + expect(downloadVideoMock).toHaveBeenCalledWith("https://cdn.example.com/video/master.m3u8", "123") + ); + }); + + it("uses indexed parent post rkeys for multi-image downloads", async () => { + downloadImageMock.mockResolvedValue({ bytes: 40, path: "/tmp/post-image.jpg" }); + render(() => ( + + )); + + fireEvent.contextMenu(screen.getByAltText("Inline image two")); + fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); + + await waitFor(() => + expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image-two.jpg", "123_2") + ); }); }); diff --git a/src/components/feeds/PostCard.tsx b/src/components/feeds/PostCard.tsx index 1f45e5b..74c9d0d 100644 --- a/src/components/feeds/PostCard.tsx +++ b/src/components/feeds/PostCard.tsx @@ -5,7 +5,7 @@ import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/com import { Icon } from "$/components/shared/Icon"; import { PostRichText } from "$/components/shared/PostRichText"; import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; -import { downloadImage } from "$/lib/api/media"; +import { MediaController } from "$/lib/api/media"; import { buildPublicPostUrl, formatRelativeTime, @@ -420,6 +420,8 @@ function PostEmbeds(props: { post: PostView }) { } function EmbedContent(props: { embed: EmbedView; post: PostView }) { + const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); + return ( @@ -436,6 +438,7 @@ function EmbedContent(props: { embed: EmbedView; post: PostView }) { @@ -476,9 +479,11 @@ function RecordWithMediaEmbedContent(props: { embed: EmbedView; post: PostView } function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { const images = createMemo(() => props.embed.images.slice(0, 4)); + const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); const [galleryStartIndex, setGalleryStartIndex] = createSignal(null); const [menuAnchor, setMenuAnchor] = createSignal(null); const [menuOpen, setMenuOpen] = createSignal(false); + const [menuImageIndex, setMenuImageIndex] = createSignal(null); const [menuImageUrl, setMenuImageUrl] = createSignal(null); const [downloadPending, setDownloadPending] = createSignal(false); const [notice, setNotice] = createSignal(null); @@ -522,6 +527,7 @@ function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { function closeMenu() { setMenuOpen(false); setMenuAnchor(null); + setMenuImageIndex(null); setMenuImageUrl(null); } @@ -530,10 +536,11 @@ function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { setGalleryStartIndex(index); } - function openImageMenu(event: MouseEvent, url: string | undefined) { + function openImageMenu(event: MouseEvent, url: string | undefined, imageIndex: number) { event.preventDefault(); event.stopPropagation(); + setMenuImageIndex(imageIndex); setMenuImageUrl(url ?? null); setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY }); setMenuOpen(true); @@ -541,13 +548,16 @@ function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { async function downloadFromContextMenu() { const url = menuImageUrl(); + const imageIndex = menuImageIndex(); if (!url || downloadPending()) { return; } setDownloadPending(true); try { - const result = await downloadImage(url); + const requestedFilename = buildImageFilename(postRkey(), images().length, imageIndex)?.trim(); + const result = await MediaController.downloadImage(url, requestedFilename ?? null); + queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); } catch (error) { queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); @@ -565,7 +575,7 @@ function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { type="button" 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)]" onClick={(event) => openGallery(index(), event)} - onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb)}> + onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb, index())}> {image.alt )} @@ -579,6 +589,7 @@ function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { open={galleryStartIndex() !== null} postText={postText()} startIndex={galleryStartIndex() ?? 0} + downloadFilenameForIndex={(imageIndex) => buildImageFilename(postRkey(), images().length, imageIndex)} onClose={() => setGalleryStartIndex(null)} /> 1 && imageIndex !== null && imageIndex >= 0) { + return `${postRkey}_${imageIndex + 1}`; + } + + return postRkey; +} + function filenameFromPath(path: string) { const parts = path.split(/[/\\]/u); return parts.at(-1) || "downloaded file"; diff --git a/src/components/feeds/VideoEmbed.test.tsx b/src/components/feeds/VideoEmbed.test.tsx index 8ca321f..0007908 100644 --- a/src/components/feeds/VideoEmbed.test.tsx +++ b/src/components/feeds/VideoEmbed.test.tsx @@ -6,7 +6,7 @@ const downloadVideoMock = vi.hoisted(() => vi.fn()); const listenMock = vi.hoisted(() => vi.fn()); const revealItemInDirMock = vi.hoisted(() => vi.fn()); -vi.mock("$/lib/api/media", () => ({ downloadVideo: downloadVideoMock })); +vi.mock("$/lib/api/media", () => ({ MediaController: { downloadVideo: downloadVideoMock } })); vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: revealItemInDirMock })); @@ -46,7 +46,9 @@ describe("VideoEmbed", () => { fireEvent.click(screen.getByRole("button", { name: "Download video" })); - await waitFor(() => expect(downloadVideoMock).toHaveBeenCalledWith("https://cdn.example.com/video/master.m3u8")); + await waitFor(() => + expect(downloadVideoMock).toHaveBeenCalledWith("https://cdn.example.com/video/master.m3u8", null) + ); expect(await screen.findByText("Saved example.mp4.")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Open in Finder" })); diff --git a/src/components/feeds/VideoEmbed.tsx b/src/components/feeds/VideoEmbed.tsx index 46afeb6..12b46d5 100644 --- a/src/components/feeds/VideoEmbed.tsx +++ b/src/components/feeds/VideoEmbed.tsx @@ -1,6 +1,7 @@ import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; import { Icon } from "$/components/shared/Icon"; -import { type DownloadProgress, downloadVideo } from "$/lib/api/media"; +import { MediaController } from "$/lib/api/media"; +import type { DownloadProgress } from "$/lib/api/types/media"; import { normalizeError } from "$/lib/utils/text"; import { listen } from "@tauri-apps/api/event"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; @@ -10,6 +11,7 @@ import type { JSX } from "solid-js"; type VideoEmbedProps = { alt?: string; aspectRatio?: { height: number; width: number }; + downloadFilename?: string; playlist?: string; thumbnail?: string; }; @@ -149,7 +151,8 @@ export function VideoEmbed(props: VideoEmbedProps) { } try { - const result = await downloadVideo(playlist); + const requestedFilename = props.downloadFilename?.trim(); + const result = await MediaController.downloadVideo(playlist, requestedFilename ?? null); queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); } catch (error) { queueNotice({ kind: "error", message: toDownloadErrorMessage(error, "Couldn't save the video right now.") }); diff --git a/src/components/settings/SettingsDownloads.tsx b/src/components/settings/SettingsDownloads.tsx index e24c5e6..b696d3d 100644 --- a/src/components/settings/SettingsDownloads.tsx +++ b/src/components/settings/SettingsDownloads.tsx @@ -1,4 +1,4 @@ -import { getDownloadDirectory, setDownloadDirectory } from "$/lib/api/media"; +import { MediaController } from "$/lib/api/media"; import type { AppSettings } from "$/lib/types"; import { normalizeError } from "$/lib/utils/text"; import { open } from "@tauri-apps/plugin-dialog"; @@ -28,7 +28,7 @@ export function SettingsDownloads(props: SettingsDownloadsProps) { async function refreshDirectory() { try { - setDirectory(await getDownloadDirectory()); + setDirectory(await MediaController.getDownloadDirectory()); } catch (error) { logger.error("failed to load download directory", { keyValues: { error: normalizeError(error) } }); queueFeedback({ kind: "error", message: "Couldn't load your download folder." }); @@ -49,7 +49,7 @@ export function SettingsDownloads(props: SettingsDownloadsProps) { return; } - await setDownloadDirectory(nextDirectory); + await MediaController.setDownloadDirectory(nextDirectory); await refreshDirectory(); queueFeedback({ kind: "success", message: "Download folder updated." }); } catch (error) { @@ -68,7 +68,7 @@ export function SettingsDownloads(props: SettingsDownloadsProps) { setPending(true); dismissFeedback(); try { - await setDownloadDirectory("~/Downloads"); + await MediaController.setDownloadDirectory("~/Downloads"); await refreshDirectory(); queueFeedback({ kind: "success", message: "Download folder reset to default." }); } catch (error) { diff --git a/src/components/settings/SettingsPanel.test.tsx b/src/components/settings/SettingsPanel.test.tsx index 32809fa..a264649 100644 --- a/src/components/settings/SettingsPanel.test.tsx +++ b/src/components/settings/SettingsPanel.test.tsx @@ -45,7 +45,9 @@ vi.mock( vi.mock( "$/lib/api/media", - () => ({ getDownloadDirectory: getDownloadDirectoryMock, setDownloadDirectory: setDownloadDirectoryMock }), + () => ({ + MediaController: { getDownloadDirectory: getDownloadDirectoryMock, setDownloadDirectory: setDownloadDirectoryMock }, + }), ); vi.mock("@tauri-apps/plugin-dialog", () => ({ open: dialogOpenMock })); diff --git a/src/lib/api/explorer.ts b/src/lib/api/explorer.ts index 8fd6687..3871032 100644 --- a/src/lib/api/explorer.ts +++ b/src/lib/api/explorer.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { ExplorerServerView, RepoCarExport, ResolvedExplorerInput } from "./types/explorer"; +import type { ExplorerServerView, RepoCarExport, ResolvedExplorerInput, TempBlobFile } from "./types/explorer"; export async function resolveInput(input: string): Promise { return invoke("resolve_input", { input }); @@ -25,6 +25,14 @@ export async function exportRepoCar(did: string): Promise { return invoke("export_repo_car", { did }); } +export async function fetchBlobToTempFile(did: string, cid: string, extension?: string | null): Promise { + return invoke("fetch_blob_to_temp_file", { cid, did, extension: extension ?? null }); +} + +export async function deleteBlobTempFile(path: string): Promise { + return invoke("delete_blob_temp_file", { path }); +} + export async function queryLabels(uri: string): Promise> { return invoke("query_labels", { uri }); } diff --git a/src/lib/api/media.ts b/src/lib/api/media.ts index 7b07270..75b552b 100644 --- a/src/lib/api/media.ts +++ b/src/lib/api/media.ts @@ -1,15 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; - -type DownloadResult = { path: string; bytes: number }; - -export type DownloadProgress = { - url: string; - path: string; - downloadedBytes: number; - downloadedSegments: number; - totalSegments: number; - complete: boolean; -}; +import type { DownloadResult } from "./types/media"; export function getDownloadDirectory() { return invoke("get_download_directory"); @@ -26,3 +16,5 @@ export function downloadImage(url: string, filename?: string | null) { export function downloadVideo(url: string, filename?: string | null) { return invoke("download_video", { filename: filename ?? null, url }); } + +export const MediaController = { getDownloadDirectory, setDownloadDirectory, downloadImage, downloadVideo }; diff --git a/src/lib/api/types/explorer.ts b/src/lib/api/types/explorer.ts index 7f37e7e..5c0485c 100644 --- a/src/lib/api/types/explorer.ts +++ b/src/lib/api/types/explorer.ts @@ -27,3 +27,5 @@ export type ExplorerServerView = { }; export type RepoCarExport = { did: string; path: string; bytesWritten: number }; + +export type TempBlobFile = { path: string; bytesWritten: number }; diff --git a/src/lib/api/types/media.ts b/src/lib/api/types/media.ts new file mode 100644 index 0000000..26e779c --- /dev/null +++ b/src/lib/api/types/media.ts @@ -0,0 +1,10 @@ +export type DownloadResult = { path: string; bytes: number }; + +export type DownloadProgress = { + url: string; + path: string; + downloadedBytes: number; + downloadedSegments: number; + totalSegments: number; + complete: boolean; +}; -- 2.51.2