diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6dc4c7..47fe5180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Gallery end-to-end integration (MAT-134)**: pasting a supported gallery + URL (Imgur, Flickr, generic pages) into the Link Grabber now expands it into + one selectable row per image, preserving gallery order, filenames, and the + `image` media type. Per-image failures surface as individual error rows + instead of cancelling the whole gallery, and an empty gallery reports a + clear "no downloadable images" error. Selected images start downloads + through the existing Tauri commands. yt-dlp-backed crawlers (YouTube, + Vimeo, SoundCloud) keep their cheap probe path and are never expanded. + A garbled plugin response reports a plugin error instead of probing the + gallery page, image URLs must parse as http(s) with a host, and expansion + is capped at the resolve output limit so an oversized gallery truncates + instead of failing the whole request. - **Premium account runtime wiring (MAT-132)**: validate configured accounts through hoster plugins, keep selected credentials scoped to the plugin call, persist typed account availability, rotate on account failures, and associate diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e115ef9..0a1761aa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7472,6 +7472,7 @@ dependencies = [ "tracing", "tracing-subscriber", "unrar", + "url", "uuid", "windows-sys 0.59.0", "wiremock", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 94788b55..e09a389a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -59,6 +59,7 @@ aes-gcm = "0.10.3" pbkdf2 = "0.13.0" hmac = "0.13.0" rand = "0.10.1" +url = "2.5.8" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index edb2392a..32018860 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -37,6 +37,7 @@ mod remove_download; mod remove_download_from_package; mod report_broken_plugin; mod resolve_links; +mod resolve_links_gallery; pub mod resolve_premium_source; mod resume_all; mod resume_download; diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index bbe78149..60bb3e22 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -12,6 +12,7 @@ use crate::application::services::account_rotator::NextAccountOutcome; use crate::application::services::download_source_policy::is_protected_plugin_category; use crate::domain::error::DomainError; use crate::domain::model::http::HttpResponse; +use crate::domain::model::plugin::PluginCategory; use crate::domain::ports::driven::ExtractedHosterLink; use super::ResolveLinksCommand; @@ -198,6 +199,28 @@ impl CommandBus { continue; } + // Crawler galleries expand into one row per image. Gated on + // `!is_media_url` so yt-dlp-backed crawlers (YouTube, Vimeo, + // SoundCloud…) keep the cheap probe path — calling + // `extract_links` on them would shell out per pasted URL. + let is_crawler = matches!( + plugin_info.as_ref().ok().and_then(Option::as_ref), + Some(info) if matches!(info.category(), PluginCategory::Crawler) + ); + if is_crawler + && !is_media_url(url) + && let Some(rows) = self.try_resolve_gallery_links( + url, + &module_name, + MAX_URLS.saturating_sub(results.len()), + ) + { + for row in rows { + push_bounded_result(&mut results, row, MAX_URLS)?; + } + continue; + } + let is_media = is_media_url(url); let media_type = if is_media { detect_media_type(url) @@ -511,7 +534,7 @@ fn sanitize_resolve_error(_e: &crate::domain::DomainError) -> String { "Could not check link status".to_string() } -fn extract_filename_from_url(url: &str) -> Option { +pub(super) fn extract_filename_from_url(url: &str) -> Option { // Strip query string and fragment let path = url.split('?').next().unwrap_or(url); let path = path.split('#').next().unwrap_or(path); diff --git a/src-tauri/src/application/commands/resolve_links_gallery.rs b/src-tauri/src/application/commands/resolve_links_gallery.rs new file mode 100644 index 00000000..dd7250e6 --- /dev/null +++ b/src-tauri/src/application/commands/resolve_links_gallery.rs @@ -0,0 +1,180 @@ +//! Gallery expansion for `handle_resolve_links`. +//! +//! Crawler plugins that answer `extract_links` with a `kind: "gallery"` +//! payload (e.g. vortex-mod-gallery) expand one gallery URL into one +//! resolved link per image, mirroring the hoster 1→N expansion. Per-image +//! failures become individual error rows so a partially invalid gallery +//! never silently cancels the valid images (MAT-134 R-05). + +use serde::Deserialize; +use uuid::Uuid; + +use crate::application::command_bus::CommandBus; + +use super::resolve_links::{LinkResolutionErrorKind, ResolvedLinkDto, extract_filename_from_url}; + +#[derive(Deserialize)] +struct GalleryResponse { + kind: Option, + #[serde(default)] + images: Vec, +} + +#[derive(Deserialize)] +struct GalleryImage { + #[serde(default)] + url: String, + #[serde(default)] + filename: Option, + #[serde(default)] + title: Option, +} + +impl CommandBus { + /// Expand a crawler gallery URL into per-image resolved links. + /// + /// Returns `None` when the payload parses but is not a gallery, so the + /// caller can fall through to the generic HEAD probe; an unparseable + /// payload becomes a single error row instead. At most `max_rows` + /// images are expanded — extras are dropped (and logged) so one + /// oversized gallery cannot fail the whole resolve. + pub(super) fn try_resolve_gallery_links( + &self, + url: &str, + module_name: &str, + max_rows: usize, + ) -> Option> { + let raw = match self.plugin_loader().extract_links(url) { + Ok(raw) => raw, + Err(e) => { + tracing::debug!(module_name, error = %e, "gallery extraction failed"); + return Some(vec![gallery_error_row( + url, + module_name, + LinkResolutionErrorKind::Plugin, + "Could not extract gallery links", + )]); + } + }; + let parsed: GalleryResponse = match serde_json::from_str(&raw) { + Ok(parsed) => parsed, + Err(e) => { + tracing::debug!(module_name, error = %e, "gallery payload is not valid JSON"); + return Some(vec![gallery_error_row( + url, + module_name, + LinkResolutionErrorKind::Plugin, + "Gallery plugin returned an invalid response", + )]); + } + }; + if parsed.kind.as_deref() != Some("gallery") { + return None; + } + if parsed.images.is_empty() { + return Some(vec![gallery_error_row( + url, + module_name, + LinkResolutionErrorKind::NoFile, + "Gallery contains no downloadable images", + )]); + } + if parsed.images.len() > max_rows { + tracing::warn!( + module_name, + total = parsed.images.len(), + kept = max_rows, + "gallery exceeds the resolve output limit; extra images dropped" + ); + } + Some( + parsed + .images + .iter() + .take(max_rows) + .enumerate() + .map(|(index, image)| gallery_image_row(url, module_name, index, image)) + .collect(), + ) + } +} + +fn gallery_image_row( + gallery_url: &str, + module_name: &str, + index: usize, + image: &GalleryImage, +) -> ResolvedLinkDto { + let image_url = image.url.trim(); + if !is_http_image_url(image_url) { + let label = image + .title + .as_deref() + .filter(|t| !t.trim().is_empty()) + .map(|t| format!("'{t}'")) + .unwrap_or_else(|| format!("#{}", index + 1)); + return gallery_error_row( + gallery_url, + module_name, + LinkResolutionErrorKind::Plugin, + &format!("Gallery item {label} has an invalid image URL"), + ); + } + let filename = image + .filename + .clone() + .filter(|name| !name.trim().is_empty()) + .or_else(|| extract_filename_from_url(image_url)); + ResolvedLinkDto { + id: Uuid::new_v4().to_string(), + original_url: image_url.to_string(), + resolved_url: Some(image_url.to_string()), + filename, + size_bytes: None, + resumable: None, + status: "online".to_string(), + error_message: None, + error_kind: None, + module_name: module_name.to_string(), + account_id: None, + is_media: false, + media_type: Some("image".to_string()), + // The gallery provider API already vouched for these URLs; + // probing every image would fire one HEAD per row for nothing. + requires_online_probe: false, + } +} + +/// `Url::parse` lowercases the scheme, so `HTTPS://…` passes; a bare +/// `https://` fails on the missing host. +fn is_http_image_url(raw: &str) -> bool { + url::Url::parse(raw).is_ok_and(|u| matches!(u.scheme(), "http" | "https") && u.has_host()) +} + +fn gallery_error_row( + gallery_url: &str, + module_name: &str, + error_kind: LinkResolutionErrorKind, + message: &str, +) -> ResolvedLinkDto { + ResolvedLinkDto { + id: Uuid::new_v4().to_string(), + original_url: gallery_url.to_string(), + resolved_url: None, + filename: None, + size_bytes: None, + resumable: None, + status: "error".to_string(), + error_message: Some(message.to_string()), + error_kind: Some(error_kind), + module_name: module_name.to_string(), + account_id: None, + is_media: false, + media_type: None, + requires_online_probe: false, + } +} + +#[cfg(test)] +#[path = "resolve_links_gallery_tests.rs"] +mod tests; diff --git a/src-tauri/src/application/commands/resolve_links_gallery_tests.rs b/src-tauri/src/application/commands/resolve_links_gallery_tests.rs new file mode 100644 index 00000000..0d63a569 --- /dev/null +++ b/src-tauri/src/application/commands/resolve_links_gallery_tests.rs @@ -0,0 +1,271 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::super::ResolveLinksCommand; +use super::super::resolve_links::{LinkResolutionErrorKind, ResolvedLinkDto}; +use super::super::tests_support::build_download_bus_with_plugin_loader; +use crate::domain::error::DomainError; +use crate::domain::model::http::HttpResponse; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; +use crate::domain::ports::driven::{HttpClient, PluginLoader}; + +const GALLERY_URL: &str = "https://imgur.com/a/abc123"; + +struct OkHttpClient; + +impl HttpClient for OkHttpClient { + fn head(&self, _url: &str) -> Result { + Ok(HttpResponse { + status_code: 200, + headers: Default::default(), + body: vec![], + }) + } + fn get_range(&self, _url: &str, _start: u64, _end: u64) -> Result, DomainError> { + Ok(vec![]) + } + fn supports_range(&self, _url: &str) -> Result { + Ok(false) + } +} + +struct CrawlerLoader { + name: &'static str, + /// `None` makes `extract_links` fail like a broken plugin. + response: Option<&'static str>, + extract_called: AtomicBool, +} + +impl CrawlerLoader { + fn new(name: &'static str, response: Option<&'static str>) -> Arc { + Arc::new(Self { + name, + response, + extract_called: AtomicBool::new(false), + }) + } +} + +impl PluginLoader for CrawlerLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(Some(PluginInfo::new( + self.name.into(), + "1.1.0".into(), + "gallery crawler".into(), + "vortex".into(), + PluginCategory::Crawler, + ))) + } + fn extract_links(&self, _: &str) -> Result { + self.extract_called.store(true, Ordering::SeqCst); + self.response + .map(str::to_string) + .ok_or_else(|| DomainError::PluginError("imgur API unreachable".into())) + } + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![]) + } + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } +} + +async fn resolve_with(loader: Arc, url: &str) -> Vec { + let (bus, _, _) = build_download_bus_with_plugin_loader(Arc::new(OkHttpClient), loader); + bus.handle_resolve_links(ResolveLinksCommand { + urls: vec![url.to_string()], + }) + .await + .expect("resolve succeeds") +} + +#[tokio::test] +async fn test_resolve_gallery_url_expands_images_in_order_with_names_and_type() { + let loader = CrawlerLoader::new( + "vortex-mod-gallery", + Some( + r#"{"kind":"gallery","provider":"imgur","images":[ + {"url":"https://i.imgur.com/a.jpg","filename":"imgur_abc123_000.jpg"}, + {"url":"https://i.imgur.com/b.png"}, + {"url":"https://i.imgur.com/c.gif","filename":"imgur_abc123_002.gif"} + ]}"#, + ), + ); + let rows = resolve_with(loader, GALLERY_URL).await; + + assert_eq!(rows.len(), 3); + let urls: Vec<_> = rows.iter().map(|r| r.original_url.as_str()).collect(); + assert_eq!( + urls, + [ + "https://i.imgur.com/a.jpg", + "https://i.imgur.com/b.png", + "https://i.imgur.com/c.gif" + ], + "gallery order must be preserved" + ); + assert_eq!(rows[0].filename.as_deref(), Some("imgur_abc123_000.jpg")); + assert_eq!( + rows[1].filename.as_deref(), + Some("b.png"), + "missing plugin filename falls back to the URL basename" + ); + for row in &rows { + assert_eq!(row.status, "online"); + assert_eq!(row.media_type.as_deref(), Some("image")); + assert_eq!(row.module_name, "vortex-mod-gallery"); + assert!(!row.is_media, "image rows must not offer the media grabber"); + assert!(!row.requires_online_probe); + assert_eq!(row.resolved_url.as_deref(), Some(row.original_url.as_str())); + } +} + +#[tokio::test] +async fn test_resolve_empty_gallery_reports_no_file_error() { + let loader = CrawlerLoader::new( + "vortex-mod-gallery", + Some(r#"{"kind":"gallery","provider":"imgur","images":[]}"#), + ); + let rows = resolve_with(loader, GALLERY_URL).await; + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, "error"); + assert_eq!(rows[0].error_kind, Some(LinkResolutionErrorKind::NoFile)); + assert_eq!(rows[0].original_url, GALLERY_URL); +} + +#[tokio::test] +async fn test_resolve_partially_invalid_gallery_keeps_valid_images_and_flags_bad_one() { + let loader = CrawlerLoader::new( + "vortex-mod-gallery", + Some( + r#"{"kind":"gallery","provider":"imgur","images":[ + {"url":"https://i.imgur.com/a.jpg"}, + {"url":"","title":"broken slide"}, + {"url":"https://i.imgur.com/c.jpg"} + ]}"#, + ), + ); + let rows = resolve_with(loader, GALLERY_URL).await; + + assert_eq!(rows.len(), 3, "invalid entries must not cancel the gallery"); + assert_eq!(rows[0].status, "online"); + assert_eq!(rows[2].status, "online"); + assert_eq!(rows[1].status, "error"); + assert_eq!(rows[1].error_kind, Some(LinkResolutionErrorKind::Plugin)); + assert!( + rows[1] + .error_message + .as_deref() + .unwrap_or_default() + .contains("broken slide"), + "error row should name the offending item" + ); +} + +#[tokio::test] +async fn test_resolve_gallery_malformed_payload_reports_plugin_error() { + let loader = CrawlerLoader::new( + "vortex-mod-gallery", + Some(r#"{"kind":"gallery","images":[{"#), + ); + let rows = resolve_with(loader, GALLERY_URL).await; + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, "error"); + assert_eq!(rows[0].error_kind, Some(LinkResolutionErrorKind::Plugin)); + assert_eq!( + rows[0].original_url, GALLERY_URL, + "garbled JSON must surface as an error, not fall through to a HEAD probe" + ); +} + +#[tokio::test] +async fn test_resolve_oversized_gallery_truncates_instead_of_failing() { + let images = (0..501) + .map(|i| format!(r#"{{"url":"https://i.imgur.com/img{i}.png"}}"#)) + .collect::>() + .join(","); + let payload = String::leak(format!(r#"{{"kind":"gallery","images":[{images}]}}"#)); + let loader = CrawlerLoader::new("vortex-mod-gallery", Some(payload)); + let rows = resolve_with(loader, GALLERY_URL).await; + + assert_eq!( + rows.len(), + 500, + "expansion stops at the resolve output limit" + ); + assert_eq!(rows[0].original_url, "https://i.imgur.com/img0.png"); + assert_eq!(rows[499].original_url, "https://i.imgur.com/img499.png"); +} + +#[tokio::test] +async fn test_resolve_gallery_rejects_host_less_and_non_http_image_urls() { + let loader = CrawlerLoader::new( + "vortex-mod-gallery", + Some( + r#"{"kind":"gallery","images":[ + {"url":"https://"}, + {"url":"ftp://i.imgur.com/a.jpg"}, + {"url":"HTTPS://I.IMGUR.COM/ok.png"} + ]}"#, + ), + ); + let rows = resolve_with(loader, GALLERY_URL).await; + + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].status, "error", "scheme-only URL has no host"); + assert_eq!(rows[1].status, "error", "non-http scheme is rejected"); + assert_eq!( + rows[2].status, "online", + "scheme matching is case-insensitive" + ); +} + +#[tokio::test] +async fn test_resolve_gallery_extraction_failure_reports_single_plugin_error() { + let loader = CrawlerLoader::new("vortex-mod-gallery", None); + let rows = resolve_with(loader.clone(), GALLERY_URL).await; + + assert!(loader.extract_called.load(Ordering::SeqCst)); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, "error"); + assert_eq!(rows[0].error_kind, Some(LinkResolutionErrorKind::Plugin)); +} + +#[tokio::test] +async fn test_resolve_non_gallery_crawler_payload_falls_back_to_head_probe() { + let loader = CrawlerLoader::new( + "vortex-mod-future", + Some(r#"{"kind":"article","items":[]}"#), + ); + let rows = resolve_with(loader, "https://example.com/some/page").await; + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, "online"); + assert!( + rows[0].requires_online_probe, + "fallback path keeps the probe" + ); + assert_eq!(rows[0].media_type, None); +} + +#[tokio::test] +async fn test_resolve_media_host_url_never_calls_extract_links() { + let loader = CrawlerLoader::new("vortex-mod-youtube", Some(r#"{"kind":"gallery"}"#)); + let rows = resolve_with(loader.clone(), "https://youtube.com/watch?v=xyz").await; + + assert!( + !loader.extract_called.load(Ordering::SeqCst), + "media-host crawlers (yt-dlp) must keep the cheap resolve path" + ); + assert_eq!(rows.len(), 1); + assert!(rows[0].is_media); + assert_eq!(rows[0].media_type.as_deref(), Some("video")); +} diff --git a/src/views/LinkGrabberView/__tests__/GalleryLinks.test.tsx b/src/views/LinkGrabberView/__tests__/GalleryLinks.test.tsx new file mode 100644 index 00000000..9fda9452 --- /dev/null +++ b/src/views/LinkGrabberView/__tests__/GalleryLinks.test.tsx @@ -0,0 +1,211 @@ +import { beforeEach, describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router"; +import { invoke } from "@tauri-apps/api/core"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { LinkGrabberView } from "../LinkGrabberView"; +import { useSettingsStore } from "@/stores/settingsStore"; +import type { ResolvedLink } from "../types"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(vi.fn()), +})); + +vi.mock("@/lib/toast", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const mockInvoke = vi.mocked(invoke); + +const GALLERY_URL = "https://imgur.com/a/abc123"; + +function galleryImageRow(id: string, url: string, filename: string | null): ResolvedLink { + return { + id, + originalUrl: url, + resolvedUrl: url, + filename, + sizeBytes: null, + resumable: null, + status: "online", + errorMessage: null, + errorKind: null, + moduleName: "vortex-mod-gallery", + accountId: null, + isMedia: false, + mediaType: "image", + requiresOnlineProbe: false, + }; +} + +function mockResolveWith(rows: ResolvedLink[]) { + mockInvoke.mockImplementation((command, args) => { + if (command === "link_resolve") return Promise.resolve(rows); + if (command === "link_detect_duplicates") { + const { urls } = args as { urls: string[] }; + return Promise.resolve( + urls.map((url) => ({ + url, + isDuplicate: false, + source: null, + existingId: null, + existingFilename: null, + })), + ); + } + return Promise.resolve(null); + }); +} + +function renderWithProviders() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render( + + + + + + + , + ); +} + +async function analyzeGallery(user: ReturnType) { + await user.type(screen.getByRole("textbox"), GALLERY_URL); + await user.click(screen.getByRole("button", { name: "Analyze Links" })); +} + +describe("LinkGrabberView gallery resolution", () => { + beforeEach(() => { + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue([]); + vi.clearAllMocks(); + useSettingsStore.setState({ config: null }); + }); + + it("should render gallery images in gallery order without a media button", async () => { + mockResolveWith([ + galleryImageRow("img-1", "https://i.imgur.com/a.jpg", "imgur_abc123_000.jpg"), + galleryImageRow("img-2", "https://i.imgur.com/b.png", "b.png"), + galleryImageRow("img-3", "https://i.imgur.com/c.gif", "imgur_abc123_002.gif"), + ]); + + const user = userEvent.setup(); + renderWithProviders(); + await analyzeGallery(user); + + await waitFor(() => { + expect(screen.getByText("imgur_abc123_002.gif")).toBeInTheDocument(); + }); + const rowIds = screen + .getAllByTestId(/^link-row-https/) + .map((row) => row.getAttribute("data-testid")); + expect(rowIds).toEqual([ + "link-row-https://i.imgur.com/a.jpg", + "link-row-https://i.imgur.com/b.png", + "link-row-https://i.imgur.com/c.gif", + ]); + expect(screen.getAllByText("vortex-mod-gallery")).toHaveLength(3); + expect(screen.queryByRole("button", { name: "image" })).not.toBeInTheDocument(); + }); + + it("should invoke download_start per selected image when Start Selected is clicked", async () => { + mockResolveWith([ + galleryImageRow("img-1", "https://i.imgur.com/a.jpg", "imgur_abc123_000.jpg"), + galleryImageRow("img-2", "https://i.imgur.com/b.png", "b.png"), + galleryImageRow("img-3", "https://i.imgur.com/c.gif", "imgur_abc123_002.gif"), + ]); + + const user = userEvent.setup(); + renderWithProviders(); + await analyzeGallery(user); + + await waitFor(() => { + expect(screen.getByText("imgur_abc123_002.gif")).toBeInTheDocument(); + }); + for (const url of ["https://i.imgur.com/a.jpg", "https://i.imgur.com/c.gif"]) { + const row = screen.getByTestId(`link-row-${url}`); + await user.click(within(row).getByRole("checkbox", { name: "Select link" })); + } + await user.click(screen.getByRole("button", { name: "Start Selected (2)" })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "download_start", + expect.objectContaining({ + url: "https://i.imgur.com/a.jpg", + moduleName: "vortex-mod-gallery", + }), + ); + expect(mockInvoke).toHaveBeenCalledWith( + "download_start", + expect.objectContaining({ url: "https://i.imgur.com/c.gif" }), + ); + }); + const startedUrls = mockInvoke.mock.calls + .filter(([command]) => command === "download_start") + .map(([, args]) => (args as { url: string }).url); + expect(startedUrls).not.toContain("https://i.imgur.com/b.png"); + }); + + it("should show a per-item error row without blocking the valid gallery images", async () => { + const errorRow: ResolvedLink = { + id: "img-err", + originalUrl: GALLERY_URL, + resolvedUrl: null, + filename: null, + sizeBytes: null, + resumable: null, + status: "error", + errorMessage: "Gallery item 'broken slide' has an invalid image URL", + errorKind: "plugin", + moduleName: "vortex-mod-gallery", + accountId: null, + isMedia: false, + requiresOnlineProbe: false, + }; + mockResolveWith([ + galleryImageRow("img-1", "https://i.imgur.com/a.jpg", "imgur_abc123_000.jpg"), + errorRow, + galleryImageRow("img-3", "https://i.imgur.com/c.jpg", "imgur_abc123_002.jpg"), + ]); + + const user = userEvent.setup(); + renderWithProviders(); + await analyzeGallery(user); + + await waitFor(() => { + expect( + screen.getByText("Gallery item 'broken slide' has an invalid image URL"), + ).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: "Start All Online" })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "download_start", + expect.objectContaining({ url: "https://i.imgur.com/a.jpg" }), + ); + expect(mockInvoke).toHaveBeenCalledWith( + "download_start", + expect.objectContaining({ url: "https://i.imgur.com/c.jpg" }), + ); + }); + const startCalls = mockInvoke.mock.calls.filter(([command]) => command === "download_start"); + expect(startCalls).toHaveLength(2); + }); +}); diff --git a/src/views/LinkGrabberView/types.ts b/src/views/LinkGrabberView/types.ts index 705a434f..81d03891 100644 --- a/src/views/LinkGrabberView/types.ts +++ b/src/views/LinkGrabberView/types.ts @@ -51,7 +51,7 @@ export interface ResolvedLink { moduleName: string; accountId: string | null; isMedia: boolean; - mediaType?: "video" | "audio"; + mediaType?: "video" | "audio" | "image"; /** Backend-owned policy: hoster pages are never probed as file URLs. */ requiresOnlineProbe?: boolean; /**