Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/application/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 24 additions & 1 deletion src-tauri/src/application/commands/resolve_links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)?;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
continue;
}

let is_media = is_media_url(url);
let media_type = if is_media {
detect_media_type(url)
Expand Down Expand Up @@ -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<String> {
pub(super) fn extract_filename_from_url(url: &str) -> Option<String> {
// Strip query string and fragment
let path = url.split('?').next().unwrap_or(url);
let path = path.split('#').next().unwrap_or(path);
Expand Down
180 changes: 180 additions & 0 deletions src-tauri/src/application/commands/resolve_links_gallery.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(default)]
images: Vec<GalleryImage>,
}

#[derive(Deserialize)]
struct GalleryImage {
#[serde(default)]
url: String,
#[serde(default)]
filename: Option<String>,
#[serde(default)]
title: Option<String>,
}

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<Vec<ResolvedLinkDto>> {
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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.images
.iter()
.take(max_rows)
.enumerate()
.map(|(index, image)| gallery_image_row(url, module_name, index, image))
.collect(),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

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"),
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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(),
Comment thread
mpiton marked this conversation as resolved.
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;
Loading
Loading