From c7739a921c5991cdef726a66011548a68e898100 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:29:51 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(resolution):=20walk=20Premium=20?= =?UTF-8?q?=E2=86=92=20Debrid=20=E2=86=92=20Free=20before=20contacting=20a?= =?UTF-8?q?=20hoster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link resolution used to pick the first plugin that claimed a URL. It now walks the resolution tiers in the order set under Settings → Downloads and records why each rung declined, so an exhausted cascade names every tier it tried instead of failing as a bare "no source". A hand-edited config can no longer disable a rung: an unknown tier fails the load loudly, and a partial or duplicated list is normalised back to the full three-tier order. Refs MAT-142 --- CHANGELOG.md | 8 + .../driven/config/toml_config_store.rs | 21 +- .../adapters/driven/plugin/extism_loader.rs | 23 +- src-tauri/src/adapters/driving/tauri_ipc.rs | 30 ++- src-tauri/src/application/commands/mod.rs | 1 + .../src/application/commands/resolve_links.rs | 52 ++-- .../commands/resolve_links_tiers.rs | 149 +++++++++++ .../commands/resolve_links_tiers_tests.rs | 248 ++++++++++++++++++ .../src/application/commands/tests_support.rs | 36 +++ src-tauri/src/domain/error.rs | 7 + src-tauri/src/domain/model/config.rs | 142 ++++++++++ .../src/domain/ports/driven/plugin_loader.rs | 10 + .../__tests__/ClipboardIndicator.test.tsx | 1 + src/hooks/__tests__/useAppEffects.test.ts | 1 + src/i18n/locales/en.json | 16 ++ src/i18n/locales/fr.json | 16 ++ src/layouts/__tests__/AppLayout.test.tsx | 1 + src/stores/__tests__/settingsStore.test.ts | 1 + src/types/settings.ts | 4 + .../__tests__/LinkGrabberView.test.tsx | 1 + src/views/SettingsView/DownloadsSection.tsx | 6 + .../SettingsView/ResolutionOrderSetting.tsx | 84 ++++++ .../__tests__/ResolutionOrderSetting.test.tsx | 63 +++++ .../SettingsView/__tests__/Sections.test.tsx | 1 + .../__tests__/SettingsView.test.tsx | 1 + 25 files changed, 889 insertions(+), 34 deletions(-) create mode 100644 src-tauri/src/application/commands/resolve_links_tiers.rs create mode 100644 src-tauri/src/application/commands/resolve_links_tiers_tests.rs create mode 100644 src/views/SettingsView/ResolutionOrderSetting.tsx create mode 100644 src/views/SettingsView/__tests__/ResolutionOrderSetting.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd126d..d284469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CAPTCHA solvers: configurable OCR → AntiCaptcha → browser cascade, typed Tesseract host broker, keyring-backed AntiCaptcha credentials, persisted per-solver attempts, and a dedicated human-assisted WebView (MAT-141). +- Debrid support: `vortex-mod-realdebrid` and `vortex-mod-alldebrid` plugins + unrestrict a covered hoster link through a keyring-held API token and report + premium expiry back to the Accounts view (MAT-142). +- Configurable Premium → Debrid → Free resolution order (PRD-v2 §4.3). Link + resolution now walks the tiers in the order set under Settings → Downloads, + picking the plugin and account before any hoster is contacted. A tier that + declines records why, so an exhausted cascade names every rung it tried + instead of failing as a bare "no source" (MAT-142). ### Security diff --git a/src-tauri/src/adapters/driven/config/toml_config_store.rs b/src-tauri/src/adapters/driven/config/toml_config_store.rs index aada5f4..8519b77 100644 --- a/src-tauri/src/adapters/driven/config/toml_config_store.rs +++ b/src-tauri/src/adapters/driven/config/toml_config_store.rs @@ -9,8 +9,9 @@ use std::sync::Mutex; use crate::domain::error::DomainError; use crate::domain::model::account::AccountSelectionStrategy; use crate::domain::model::config::{ - AppConfig, ConfigPatch, MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS, apply_patch, - normalize_captcha_solver_order, normalize_history_retention_days, + AppConfig, ConfigPatch, MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS, + ResolutionTier, apply_patch, normalize_captcha_solver_order, normalize_history_retention_days, + normalize_resolution_order, }; use crate::domain::ports::driven::ConfigStore; @@ -172,6 +173,7 @@ struct ConfigDto { // Accounts account_selection_strategy: String, + resolution_order: Vec, // Network proxy_type: String, @@ -234,6 +236,11 @@ impl From for ConfigDto { captcha_solver_order: c.captcha_solver_order, history_retention_days: c.history_retention_days, account_selection_strategy: c.account_selection_strategy.to_string(), + resolution_order: c + .resolution_order + .iter() + .map(ResolutionTier::to_string) + .collect(), proxy_type: c.proxy_type, proxy_url: c.proxy_url, user_agent: c.user_agent, @@ -272,6 +279,15 @@ impl TryFrom for AppConfig { } else { d.account_selection_strategy.parse()? }; + // Same backward-compat contract: an absent list means "never + // configured" and falls back to the PRD default, while a typo'd + // tier is corruption and must not silently disable a rung. + let resolution_order = normalize_resolution_order( + &d.resolution_order + .iter() + .map(|tier| tier.parse()) + .collect::, DomainError>>()?, + ); Ok(Self { download_dir: d.download_dir, start_minimized: d.start_minimized, @@ -296,6 +312,7 @@ impl TryFrom for AppConfig { captcha_solver_order: normalize_captcha_solver_order(&d.captcha_solver_order), history_retention_days: normalize_history_retention_days(d.history_retention_days), account_selection_strategy, + resolution_order, proxy_type: d.proxy_type, proxy_url: d.proxy_url, user_agent: d.user_agent, diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs index 4b0acb4..962d15b 100644 --- a/src-tauri/src/adapters/driven/plugin/extism_loader.rs +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -470,7 +470,10 @@ impl PluginLoader for ExtismPluginLoader { .into_iter() .filter(|i| i.is_enabled()) .collect(); - infos.sort_by(|a, b| a.name().cmp(b.name())); + // A debrid plugin claims every hoster it can unrestrict, so on name + // order alone it would steal URLs the hoster plugin owns. Debrid is a + // fallback rung of the resolution cascade, never the URL owner. + infos.sort_by_key(|i| (i.category() == PluginCategory::Debrid, i.name().to_string())); for info in infos { let name = info.name().to_string(); match self.registry.call_plugin(&name, "can_handle", url) { @@ -488,6 +491,24 @@ impl PluginLoader for ExtismPluginLoader { Ok(None) } + fn plugin_can_handle(&self, name: &str, url: &str) -> Result { + let enabled = self + .registry + .list_info() + .into_iter() + .any(|i| i.name() == name && i.is_enabled()); + if !enabled { + return Ok(false); + } + match self.registry.call_plugin(name, "can_handle", url) { + Ok(result) => Ok(result.trim() == "true"), + Err(e) => { + tracing::warn!("plugin '{name}' failed can_handle call: {e}"); + Ok(false) + } + } + } + fn list_loaded(&self) -> Result, DomainError> { Ok(self.registry.list_info()) } diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index ddfdc91..ff30e50 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -76,7 +76,7 @@ use crate::application::read_models::stats_view::{ModuleStatsDto, StatsViewDto}; use crate::domain::error::DomainError; use crate::domain::model::account::{AccountId, AccountType}; use crate::domain::model::captcha::{CaptchaId, MAX_CAPTCHA_SOLUTION_BYTES}; -use crate::domain::model::config::{AppConfig, ConfigPatch}; +use crate::domain::model::config::{AppConfig, ConfigPatch, ResolutionTier}; use crate::domain::model::download::{DownloadId, DownloadState}; use crate::domain::model::package::{PackageId, PackageSourceType}; use crate::domain::model::views::{ @@ -1316,6 +1316,9 @@ pub struct SettingsDto { /// Serialized as `"best_traffic" | "round_robin" | "manual"` to mirror /// the snake_case enum convention used elsewhere in IPC payloads. pub account_selection_strategy: String, + /// Link resolution cascade, most-preferred first. Entries are + /// `"premium" | "debrid" | "free"`. + pub resolution_order: Vec, // Network pub proxy_type: String, @@ -1377,6 +1380,11 @@ impl From for SettingsDto { captcha_solver_order: c.captcha_solver_order, history_retention_days: c.history_retention_days, account_selection_strategy: c.account_selection_strategy.to_string(), + resolution_order: c + .resolution_order + .iter() + .map(ResolutionTier::to_string) + .collect(), proxy_type: c.proxy_type, proxy_url: c.proxy_url, user_agent: c.user_agent, @@ -1402,6 +1410,9 @@ impl From for SettingsDto { const MAX_CAPTCHA_SOLVER_ORDER_ENTRIES: usize = 3; const MAX_CAPTCHA_SOLVER_IDENTIFIER_BYTES: usize = 128; +/// One entry per `ResolutionTier` variant; duplicates are dropped by +/// `normalize_resolution_order`, so a longer list is malformed input. +const MAX_RESOLUTION_ORDER_ENTRIES: usize = 3; #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -1438,6 +1449,9 @@ pub struct ConfigPatchDto { /// Accepted values: `"best_traffic"`, `"round_robin"`, `"manual"`. /// Unknown values are rejected by `ConfigPatch::try_from(ConfigPatchDto)`. pub account_selection_strategy: Option, + /// Accepted values: `"premium"`, `"debrid"`, `"free"`. Unknown values + /// are rejected by `ConfigPatch::try_from(ConfigPatchDto)`. + pub resolution_order: Option>, // Network pub proxy_type: Option, @@ -1488,6 +1502,19 @@ impl TryFrom for ConfigPatch { Some(raw) => Some(raw.parse().map_err(|e: DomainError| e.to_string())?), None => None, }; + let resolution_order = match &d.resolution_order { + Some(order) if order.len() > MAX_RESOLUTION_ORDER_ENTRIES => { + return Err("Resolution order exceeds safety limits".to_string()); + } + Some(order) => Some( + order + .iter() + .map(|tier| tier.parse()) + .collect::, DomainError>>() + .map_err(|e| e.to_string())?, + ), + None => None, + }; Ok(Self { download_dir: d.download_dir, start_minimized: d.start_minimized, @@ -1510,6 +1537,7 @@ impl TryFrom for ConfigPatch { captcha_solver_order: d.captcha_solver_order, history_retention_days: d.history_retention_days, account_selection_strategy, + resolution_order, proxy_type: d.proxy_type, proxy_url: d.proxy_url, user_agent: d.user_agent, diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 5588258..c511cd1 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -42,6 +42,7 @@ mod remove_download_from_package; mod report_broken_plugin; mod resolve_links; mod resolve_links_gallery; +mod resolve_links_tiers; 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 51fb8a0..cab1676 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -7,8 +7,8 @@ use serde::Serialize; use uuid::Uuid; use crate::application::command_bus::CommandBus; +use crate::application::commands::resolve_links_tiers::TierPlan; use crate::application::error::AppError; -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; @@ -57,6 +57,9 @@ struct HosterResolution { size_bytes: Option, resumable: Option, account_id: Option, + /// Plugin the cascade picked. Differs from the plugin that claimed the + /// URL when a debrid service won the tier walk. + module_name: String, } impl CommandBus { @@ -161,7 +164,7 @@ impl CommandBus { status: "online".to_string(), error_message: None, error_kind: None, - module_name: module_name.clone(), + module_name: resolved.module_name, account_id: resolved.account_id, is_media: false, media_type: None, @@ -312,23 +315,17 @@ impl CommandBus { if service_name == "vortex-mod-gofile" { validate_gofile_requested_origin(url)?; } - if self.account_repo().is_some() { - match self.next_hoster_account(service_name)? { - NextAccountOutcome::Picked(account) => { - return Ok(vec![HosterResolution { - stable_url: url.to_string(), - filename: extract_filename_from_url(url) - .unwrap_or_else(|| "download".into()), - size_bytes: None, - resumable: None, - account_id: Some(account.id().as_str().to_string()), - }]); - } - NextAccountOutcome::AllExhausted { reason, .. } => { - return Err(reason.into_domain_error().into()); - } - NextAccountOutcome::NoneAvailable => {} - } + if let TierPlan::WithAccount { module, account_id } = + self.walk_resolution_tiers(url, service_name)? + { + return Ok(vec![HosterResolution { + stable_url: url.to_string(), + filename: extract_filename_from_url(url).unwrap_or_else(|| "download".into()), + size_bytes: None, + resumable: None, + account_id: Some(account_id), + module_name: module, + }]); } let links = self @@ -343,17 +340,6 @@ impl CommandBus { .collect::, _>>() .map_err(Into::into) } - - fn next_hoster_account(&self, service_name: &str) -> Result { - let Some(rotator) = self.account_rotator() else { - return Ok(match self.resolve_account_for(service_name)? { - Some(account) => NextAccountOutcome::Picked(account), - None => NextAccountOutcome::NoneAvailable, - }); - }; - let strategy = self.config_store().get_config()?.account_selection_strategy; - rotator.next_account(service_name, strategy) - } } fn push_bounded_result( @@ -405,6 +391,7 @@ fn into_hoster_resolution( size_bytes: link.size_bytes, resumable: link.resumable, account_id: None, + module_name: service_name.to_string(), }) } @@ -502,6 +489,11 @@ fn hoster_error_details(error: &AppError) -> (LinkResolutionErrorKind, String) { LinkResolutionErrorKind::NoFile, "No downloadable file was found".to_string(), ), + // Host-authored text listing each declined tier — safe to surface + // verbatim, unlike the plugin-authored messages below. + AppError::Domain(error @ DomainError::ResolutionExhausted(_)) => { + (LinkResolutionErrorKind::NoFile, error.to_string()) + } AppError::Domain(DomainError::AccountCooldown) => ( LinkResolutionErrorKind::AccountUnavailable, "Account is temporarily rate-limited".to_string(), diff --git a/src-tauri/src/application/commands/resolve_links_tiers.rs b/src-tauri/src/application/commands/resolve_links_tiers.rs new file mode 100644 index 0000000..32e1b51 --- /dev/null +++ b/src-tauri/src/application/commands/resolve_links_tiers.rs @@ -0,0 +1,149 @@ +//! The configurable Premium → Debrid → Free resolution cascade (PRD §4.3). +//! +//! Walking the tiers only *picks* a plugin and an account; no hoster is +//! contacted here. The chosen module is persisted on the download and the +//! unrestrict call happens later, in `ResolveHosterSourceHandler`. + +use crate::application::command_bus::CommandBus; +use crate::application::error::AppError; +use crate::application::services::account_rotator::NextAccountOutcome; +use crate::domain::error::DomainError; +use crate::domain::model::config::ResolutionTier; +use crate::domain::model::plugin::PluginCategory; + +/// What the cascade decided to do with a URL. +pub(super) enum TierPlan { + /// Resolve through `module` with this account's credential. + WithAccount { module: String, account_id: String }, + /// Resolve anonymously through the plugin that owns the URL. + Anonymous, +} + +impl CommandBus { + /// Walk `resolution_order` until a tier yields a plan. + /// + /// `service_name` is the plugin that claimed the URL. Every tier that + /// declines records why, so the final error names each rung instead of + /// collapsing to a bare "no source" (R-04). + pub(super) fn walk_resolution_tiers( + &self, + url: &str, + service_name: &str, + ) -> Result { + let order = self.config_store().get_config()?.resolution_order; + let matched_is_debrid = self.is_debrid_plugin(service_name)?; + let mut skipped: Vec = Vec::with_capacity(order.len()); + + for tier in order { + let outcome = match tier { + ResolutionTier::Premium if matched_is_debrid => { + Err(format!("{service_name} is a debrid service, not a hoster")) + } + ResolutionTier::Premium => self.premium_tier(service_name)?, + ResolutionTier::Debrid => self.debrid_tier(url)?, + ResolutionTier::Free if matched_is_debrid => { + Err("debrid services have no anonymous mode".to_string()) + } + ResolutionTier::Free => return Ok(TierPlan::Anonymous), + }; + match outcome { + Ok(plan) => return Ok(plan), + Err(reason) => skipped.push(format!("{tier}: {reason}")), + } + } + Err(DomainError::ResolutionExhausted(skipped.join("; ")).into()) + } + + /// A premium account registered against the hoster plugin itself. + /// + /// Exhaustion is *not* a skip: the rotator's contract is that the caller + /// waits for the cooldown rather than silently downgrading a paid account + /// to the free path. + fn premium_tier(&self, service_name: &str) -> Result, AppError> { + if self.account_repo().is_none() { + return Ok(Err("no account store configured".to_string())); + } + match self.next_hoster_account(service_name)? { + NextAccountOutcome::Picked(account) => Ok(Ok(TierPlan::WithAccount { + module: service_name.to_string(), + account_id: account.id().as_str().to_string(), + })), + NextAccountOutcome::AllExhausted { reason, .. } => { + Err(AppError::Domain(reason.into_domain_error())) + } + NextAccountOutcome::NoneAvailable => { + Ok(Err(format!("no premium account for {service_name}"))) + } + } + } + + /// Any enabled debrid plugin that covers this hoster and has a usable + /// account. A covered-but-exhausted debrid falls through (R-04) — unlike + /// premium, there is no per-hoster subscription to wait on. + fn debrid_tier(&self, url: &str) -> Result, AppError> { + if self.account_repo().is_none() { + return Ok(Err("no account store configured".to_string())); + } + let candidates = self.debrid_plugins_covering(url)?; + if candidates.is_empty() { + return Ok(Err("no debrid service covers this hoster".to_string())); + } + let mut reasons = Vec::with_capacity(candidates.len()); + for name in candidates { + match self.next_hoster_account(&name)? { + NextAccountOutcome::Picked(account) => { + return Ok(Ok(TierPlan::WithAccount { + module: name, + account_id: account.id().as_str().to_string(), + })); + } + NextAccountOutcome::AllExhausted { reason, .. } => { + reasons.push(format!("{name} {:?}", reason).to_lowercase()); + } + NextAccountOutcome::NoneAvailable => reasons.push(format!("{name} no account")), + } + } + Ok(Err(reasons.join(", "))) + } + + fn debrid_plugins_covering(&self, url: &str) -> Result, AppError> { + let mut names: Vec = self + .plugin_loader() + .list_loaded()? + .into_iter() + .filter(|info| info.is_enabled() && info.category() == PluginCategory::Debrid) + .map(|info| info.name().to_string()) + .collect(); + names.sort(); + let mut covering = Vec::with_capacity(names.len()); + for name in names { + if self.plugin_loader().plugin_can_handle(&name, url)? { + covering.push(name); + } + } + Ok(covering) + } + + fn next_hoster_account(&self, service_name: &str) -> Result { + let Some(rotator) = self.account_rotator() else { + return Ok(match self.resolve_account_for(service_name)? { + Some(account) => NextAccountOutcome::Picked(account), + None => NextAccountOutcome::NoneAvailable, + }); + }; + let strategy = self.config_store().get_config()?.account_selection_strategy; + rotator.next_account(service_name, strategy) + } + + fn is_debrid_plugin(&self, service_name: &str) -> Result { + Ok(self + .plugin_loader() + .list_loaded()? + .into_iter() + .any(|info| info.name() == service_name && info.category() == PluginCategory::Debrid)) + } +} + +#[cfg(test)] +#[path = "resolve_links_tiers_tests.rs"] +mod tests; diff --git a/src-tauri/src/application/commands/resolve_links_tiers_tests.rs b/src-tauri/src/application/commands/resolve_links_tiers_tests.rs new file mode 100644 index 0000000..f7cb739 --- /dev/null +++ b/src-tauri/src/application/commands/resolve_links_tiers_tests.rs @@ -0,0 +1,248 @@ +//! Cascade tests for the configurable Premium → Debrid → Free order (R-03/R-04). + +use std::sync::{Arc, Mutex}; + +use super::*; +use crate::application::commands::tests_support::{ + InMemoryAccountRepo, build_account_bus_with_config, +}; +use crate::application::commands::{ResolveLinksCommand, ResolvedLinkDto}; +use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +use crate::domain::model::config::{AppConfig, ConfigPatch, default_resolution_order}; +use crate::domain::model::credential::Credential; +use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::ports::driven::{ + AccountRepository, ConfigStore, ExtractedHosterLink, PluginLoader, +}; + +const HOSTER: &str = "vortex-mod-mediafire"; +const DEBRID: &str = "vortex-mod-alldebrid"; +const URL: &str = "https://www.mediafire.com/file/abc/archive.zip/file"; + +/// One hoster plugin owning the URL plus one debrid plugin that also +/// claims it, which is the configuration the cascade exists to arbitrate. +struct CascadeLoader { + debrid_covers: bool, + anonymous_calls: Mutex>, +} + +impl CascadeLoader { + fn new(debrid_covers: bool) -> Self { + Self { + debrid_covers, + anonymous_calls: Mutex::new(Vec::new()), + } + } + + fn info(name: &str) -> PluginInfo { + let category = if name == DEBRID { + PluginCategory::Debrid + } else { + PluginCategory::Hoster + }; + PluginInfo::new( + name.to_string(), + "1.0.0".into(), + name.to_string(), + "vortex".into(), + category, + ) + } +} + +impl PluginLoader for CascadeLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(Some(Self::info(HOSTER))) + } + + fn plugin_can_handle(&self, name: &str, _: &str) -> Result { + Ok(name != DEBRID || self.debrid_covers) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![Self::info(HOSTER), Self::info(DEBRID)]) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn extract_hoster_links( + &self, + service_name: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result, DomainError> { + assert!(credential.is_none(), "the free tier is anonymous"); + self.anonymous_calls + .lock() + .expect("call log") + .push(service_name.to_string()); + Ok(vec![ExtractedHosterLink { + source_url: url.to_string(), + filename: Some("archive.zip".into()), + size_bytes: Some(42), + direct_url: Some("https://cdn.example/archive.zip".into()), + resumable: Some(true), + request_headers: Vec::new(), + traffic_used_bytes: None, + traffic_total_bytes: None, + captcha: None, + }]) + } +} + +struct OrderedConfigStore(Vec); + +impl ConfigStore for OrderedConfigStore { + fn get_config(&self) -> Result { + Ok(AppConfig { + resolution_order: self.0.clone(), + ..AppConfig::default() + }) + } + + fn update_config(&self, _: ConfigPatch) -> Result { + self.get_config() + } +} + +fn account(id: &str, service: &str, status: AccountStatus) -> Account { + let mut account = Account::new( + AccountId::new(id), + service.to_string(), + "user".into(), + AccountType::Debrid, + 0, + ); + account.set_status(status); + account +} + +async fn resolve( + order: Vec, + accounts: Vec, + debrid_covers: bool, +) -> (Vec, Arc) { + let repo = Arc::new(InMemoryAccountRepo::new()); + for account in accounts { + repo.save(&account).expect("seed account"); + } + let plugins = Arc::new(CascadeLoader::new(debrid_covers)); + let bus = + build_account_bus_with_config(repo, plugins.clone(), Arc::new(OrderedConfigStore(order))); + let resolved = bus + .handle_resolve_links(ResolveLinksCommand { + urls: vec![URL.into()], + }) + .await + .expect("resolution reports per-link status, never a bus error"); + (resolved, plugins) +} + +#[tokio::test] +async fn test_premium_account_wins_over_debrid_in_the_default_order() { + let (resolved, plugins) = resolve( + default_resolution_order(), + vec![ + account("premium-1", HOSTER, AccountStatus::Valid), + account("debrid-1", DEBRID, AccountStatus::Valid), + ], + true, + ) + .await; + + assert_eq!(resolved[0].module_name, HOSTER); + assert_eq!(resolved[0].account_id.as_deref(), Some("premium-1")); + assert!(plugins.anonymous_calls.lock().expect("call log").is_empty()); +} + +#[tokio::test] +async fn test_debrid_resolves_the_link_when_no_premium_account_exists() { + let (resolved, plugins) = resolve( + default_resolution_order(), + vec![account("debrid-1", DEBRID, AccountStatus::Valid)], + true, + ) + .await; + + assert_eq!(resolved[0].module_name, DEBRID); + assert_eq!(resolved[0].account_id.as_deref(), Some("debrid-1")); + assert!(plugins.anonymous_calls.lock().expect("call log").is_empty()); +} + +#[tokio::test] +async fn test_reordering_the_cascade_puts_debrid_ahead_of_a_premium_account() { + let (resolved, _) = resolve( + vec![ + ResolutionTier::Debrid, + ResolutionTier::Premium, + ResolutionTier::Free, + ], + vec![ + account("premium-1", HOSTER, AccountStatus::Valid), + account("debrid-1", DEBRID, AccountStatus::Valid), + ], + true, + ) + .await; + + assert_eq!(resolved[0].module_name, DEBRID); + assert_eq!(resolved[0].account_id.as_deref(), Some("debrid-1")); +} + +#[tokio::test] +async fn test_debrid_that_does_not_cover_the_hoster_falls_through_to_free() { + let (resolved, plugins) = resolve( + default_resolution_order(), + vec![account("debrid-1", DEBRID, AccountStatus::Valid)], + false, + ) + .await; + + assert_eq!(resolved[0].module_name, HOSTER); + assert_eq!(resolved[0].account_id, None); + assert_eq!(resolved[0].status, "online"); + assert_eq!(*plugins.anonymous_calls.lock().expect("call log"), [HOSTER]); +} + +#[tokio::test] +async fn test_exhausted_debrid_account_falls_through_to_free() { + let (resolved, plugins) = resolve( + default_resolution_order(), + vec![account("debrid-1", DEBRID, AccountStatus::QuotaExhausted)], + true, + ) + .await; + + assert_eq!(resolved[0].module_name, HOSTER); + assert_eq!(resolved[0].status, "online"); + assert_eq!(*plugins.anonymous_calls.lock().expect("call log"), [HOSTER]); +} + +#[tokio::test] +async fn test_cascade_with_no_usable_tier_reports_every_rung_it_tried() { + let (resolved, plugins) = resolve( + vec![ResolutionTier::Premium, ResolutionTier::Debrid], + Vec::new(), + true, + ) + .await; + + assert_eq!(resolved[0].status, "error"); + let message = resolved[0] + .error_message + .as_deref() + .expect("an exhausted cascade explains itself"); + assert!(message.contains("premium"), "{message}"); + assert!(message.contains("debrid"), "{message}"); + assert!(plugins.anonymous_calls.lock().expect("call log").is_empty()); +} diff --git a/src-tauri/src/application/commands/tests_support.rs b/src-tauri/src/application/commands/tests_support.rs index cb1bc2a..b0510bb 100644 --- a/src-tauri/src/application/commands/tests_support.rs +++ b/src-tauri/src/application/commands/tests_support.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex}; use crate::application::command_bus::CommandBus; use crate::application::services::account_operation_locks::AccountOperationLocks; +use crate::application::services::{AccountRotator, AccountSelector}; use crate::application::test_support::NoopHistoryRepo; use crate::domain::error::DomainError; use crate::domain::event::DomainEvent; @@ -901,6 +902,41 @@ pub(crate) fn build_account_bus_with_plugin_loader( bus } +/// Account bus whose config store is supplied by the caller, so a test can +/// drive the Premium → Debrid → Free cascade through `resolution_order`. +pub(crate) fn build_account_bus_with_config( + account_repo: Arc, + plugin_loader: Arc, + config_store: Arc, +) -> CommandBus { + let events = Arc::new(CapturingEventBus::new()); + let clock: Arc = Arc::new(FixedAccountClock); + CommandBus::new( + Arc::new(StubDownloadRepo), + Arc::new(StubDownloadEngine), + events.clone(), + Arc::new(StubFileStorage), + Arc::new(StubHttpClient), + plugin_loader, + config_store, + Arc::new(StubCredentialStore), + Arc::new(StubClipboardObserver), + Arc::new(StubArchiveExtractor), + Arc::new(NoopHistoryRepo), + None, + ) + .with_account_repo(account_repo.clone()) + .with_account_credential_store(Arc::new(FakeAccountCredentialStore::new())) + .with_account_clock(clock.clone()) + .with_account_operation_locks(Arc::new(AccountOperationLocks::default())) + .with_account_rotator(AccountRotator::new( + AccountSelector::new(account_repo.clone(), events.clone(), clock.clone()), + account_repo, + events, + clock, + )) +} + pub(crate) fn build_credential_bus(credential_store: Arc) -> CommandBus { CommandBus::new( Arc::new(StubDownloadRepo), diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index be48873..e8f8a5a 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -31,6 +31,10 @@ pub enum DomainError { HosterAuthenticationRequired, HosterDirectUrlExpired, HosterUnexpectedHtml, + /// Every rung of the Premium → Debrid → Free cascade declined the link. + /// The payload lists each tier and why, so the failure is never a bare + /// "no source" the user cannot act on (PRD §4.3). + ResolutionExhausted(String), CaptchaRequired { challenge_type: CaptchaType, challenge_url: String, @@ -117,6 +121,9 @@ impl std::fmt::Display for DomainError { DomainError::HosterUnexpectedHtml => { write!(f, "Hoster returned an HTML page instead of file content") } + DomainError::ResolutionExhausted(reasons) => { + write!(f, "No resolution tier could handle this link ({reasons})") + } DomainError::CaptchaRequired { .. } => write!(f, "CAPTCHA is required"), DomainError::AdaptiveStreamOnly => write!( f, diff --git a/src-tauri/src/domain/model/config.rs b/src-tauri/src/domain/model/config.rs index 18292ee..ca685fa 100644 --- a/src-tauri/src/domain/model/config.rs +++ b/src-tauri/src/domain/model/config.rs @@ -3,6 +3,10 @@ //! Used by `ConfigStore` port for reading and updating settings. //! These types live in the domain because the port traits reference them. +use std::fmt; +use std::str::FromStr; + +use crate::domain::error::DomainError; use crate::domain::model::account::AccountSelectionStrategy; /// Application-wide configuration. @@ -61,6 +65,9 @@ pub struct AppConfig { /// for the same service. PRD §6.4 — "Auto-select du meilleur /// compte disponible". pub account_selection_strategy: AccountSelectionStrategy, + /// Order in which link resolution walks the available tiers. + /// PRD §4.3 — "Priorité de résolution (configurable)". + pub resolution_order: Vec, // ── Network ────────────────────────────────────────────────────── /// `"none"`, `"http"`, or `"socks5"`. @@ -145,6 +152,7 @@ impl Default for AppConfig { // Accounts account_selection_strategy: AccountSelectionStrategy::DEFAULT, + resolution_order: default_resolution_order(), // Network proxy_type: "none".to_string(), @@ -226,6 +234,68 @@ pub fn normalize_captcha_solver_order(raw: &[String]) -> Vec { } } +/// One rung of the link resolution cascade. PRD §4.3. +/// +/// The set is closed, so this is an enum rather than the open-ended +/// plugin-name strings used by `captcha_solver_order`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolutionTier { + /// A premium account registered against the hoster plugin itself. + Premium, + /// A debrid service that covers the hoster. + Debrid, + /// Anonymous extraction through the hoster plugin (wait + captcha). + Free, +} + +impl fmt::Display for ResolutionTier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + ResolutionTier::Premium => "premium", + ResolutionTier::Debrid => "debrid", + ResolutionTier::Free => "free", + }) + } +} + +impl FromStr for ResolutionTier { + type Err = DomainError; + + fn from_str(raw: &str) -> Result { + match raw { + "premium" => Ok(ResolutionTier::Premium), + "debrid" => Ok(ResolutionTier::Debrid), + "free" => Ok(ResolutionTier::Free), + other => Err(DomainError::ValidationError(format!( + "invalid resolution tier: {other}" + ))), + } + } +} + +/// PRD §4.3 default: a hoster-specific premium account outranks a debrid +/// subscription, which outranks anonymous free extraction. +pub fn default_resolution_order() -> Vec { + vec![ + ResolutionTier::Premium, + ResolutionTier::Debrid, + ResolutionTier::Free, + ] +} + +/// Drop duplicates from a persisted order and append any tier the user +/// left out, so every rung stays reachable and no link becomes +/// unresolvable through a hand-edited config. +pub fn normalize_resolution_order(raw: &[ResolutionTier]) -> Vec { + let mut normalized: Vec = Vec::with_capacity(3); + for tier in raw.iter().chain(default_resolution_order().iter()) { + if !normalized.contains(tier) { + normalized.push(*tier); + } + } + normalized +} + /// Lower bound for `link_check_parallelism`. Below 1 the queue stalls. pub const MIN_LINK_CHECK_PARALLELISM: u32 = 1; @@ -279,6 +349,7 @@ pub struct ConfigPatch { // Accounts pub account_selection_strategy: Option, + pub resolution_order: Option>, // Network pub proxy_type: Option, @@ -415,6 +486,9 @@ pub fn apply_patch(config: &mut AppConfig, patch: &ConfigPatch) { if let Some(v) = patch.account_selection_strategy { config.account_selection_strategy = v; } + if let Some(ref order) = patch.resolution_order { + config.resolution_order = normalize_resolution_order(order); + } // Network if let Some(ref v) = patch.proxy_type { @@ -699,6 +773,74 @@ mod tests { assert_eq!(config.link_check_timeout_secs, 1); } + #[test] + fn test_default_resolution_order_is_premium_then_debrid_then_free() { + assert_eq!( + AppConfig::default().resolution_order, + vec![ + ResolutionTier::Premium, + ResolutionTier::Debrid, + ResolutionTier::Free + ] + ); + } + + #[test] + fn test_apply_patch_reorders_resolution_tiers() { + let mut config = AppConfig::default(); + let patch = ConfigPatch { + resolution_order: Some(vec![ResolutionTier::Debrid, ResolutionTier::Premium]), + ..Default::default() + }; + apply_patch(&mut config, &patch); + assert_eq!( + config.resolution_order, + vec![ + ResolutionTier::Debrid, + ResolutionTier::Premium, + ResolutionTier::Free + ], + "omitted tiers are appended so every rung stays reachable" + ); + } + + #[test] + fn test_normalize_resolution_order_drops_duplicates() { + assert_eq!( + normalize_resolution_order(&[ + ResolutionTier::Free, + ResolutionTier::Free, + ResolutionTier::Debrid + ]), + vec![ + ResolutionTier::Free, + ResolutionTier::Debrid, + ResolutionTier::Premium + ] + ); + } + + #[test] + fn test_normalize_resolution_order_of_empty_input_is_the_default() { + assert_eq!(normalize_resolution_order(&[]), default_resolution_order()); + } + + #[test] + fn test_resolution_tier_round_trips_through_its_wire_name() { + for tier in default_resolution_order() { + let rendered = tier.to_string(); + assert_eq!( + ResolutionTier::from_str(&rendered).expect("tier parses back"), + tier + ); + } + } + + #[test] + fn test_resolution_tier_rejects_unknown_name() { + assert!(ResolutionTier::from_str("torrent").is_err()); + } + #[test] fn test_normalize_link_check_parallelism_clamps_zero_to_min() { assert_eq!( diff --git a/src-tauri/src/domain/ports/driven/plugin_loader.rs b/src-tauri/src/domain/ports/driven/plugin_loader.rs index 874c41f..083dde4 100644 --- a/src-tauri/src/domain/ports/driven/plugin_loader.rs +++ b/src-tauri/src/domain/ports/driven/plugin_loader.rs @@ -63,6 +63,16 @@ pub trait PluginLoader: Send + Sync { /// the built-in HTTP module). fn resolve_url(&self, url: &str) -> Result, DomainError>; + /// Ask one named plugin whether it claims the URL. + /// + /// `resolve_url` only reports the winner; the resolution cascade also + /// needs to poll debrid plugins that lost to the hoster that owns the + /// domain. The default answers "does not claim" so a loader without + /// per-plugin dispatch makes the cascade skip a rung rather than abort. + fn plugin_can_handle(&self, _name: &str, _url: &str) -> Result { + Ok(false) + } + /// List all currently loaded plugins. fn list_loaded(&self) -> Result, DomainError>; diff --git a/src/components/__tests__/ClipboardIndicator.test.tsx b/src/components/__tests__/ClipboardIndicator.test.tsx index d30810b..711da75 100644 --- a/src/components/__tests__/ClipboardIndicator.test.tsx +++ b/src/components/__tests__/ClipboardIndicator.test.tsx @@ -38,6 +38,7 @@ const baseConfig: AppConfig = { "vortex-mod-captcha-anticaptcha", "vortex-mod-captcha-browser", ], + resolutionOrder: ["premium", "debrid", "free"], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/hooks/__tests__/useAppEffects.test.ts b/src/hooks/__tests__/useAppEffects.test.ts index ccc9cc8..4687b01 100644 --- a/src/hooks/__tests__/useAppEffects.test.ts +++ b/src/hooks/__tests__/useAppEffects.test.ts @@ -35,6 +35,7 @@ const baseConfig: AppConfig = { "vortex-mod-captcha-anticaptcha", "vortex-mod-captcha-browser", ], + resolutionOrder: ["premium", "debrid", "free"], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e22ecdf..60a6f42 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -127,6 +127,22 @@ "speedLimitDesc": "0 = unlimited", "maxRetries": "Max retries", "retryDelay": "Retry delay (seconds)", + "resolutionOrder": { + "label": "Resolution priority", + "description": "Order in which a link is resolved. The first tier that can serve the link wins.", + "tiers": { + "premium": "Premium account", + "debrid": "Debrid service", + "free": "Free" + }, + "hints": { + "premium": "A paid account registered directly with the file host.", + "debrid": "A Real-Debrid or AllDebrid subscription covering the host.", + "free": "Anonymous download, with the wait and CAPTCHA the host imposes." + }, + "moveUp": "Move {{tier}} up", + "moveDown": "Move {{tier}} down" + }, "verifyChecksums": "Verify checksums", "verifyChecksumsDesc": "Verify file integrity after download", "preAllocate": "Pre-allocate space", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a670e97..dbab41b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -127,6 +127,22 @@ "speedLimitDesc": "0 = illimité", "maxRetries": "Tentatives max", "retryDelay": "Délai de tentative (secondes)", + "resolutionOrder": { + "label": "Priorité de résolution", + "description": "Ordre de résolution d'un lien. Le premier niveau capable de servir le lien l'emporte.", + "tiers": { + "premium": "Compte premium", + "debrid": "Service debrid", + "free": "Gratuit" + }, + "hints": { + "premium": "Un compte payant enregistré directement chez l'hébergeur.", + "debrid": "Un abonnement Real-Debrid ou AllDebrid couvrant l'hébergeur.", + "free": "Téléchargement anonyme, avec l'attente et le CAPTCHA imposés par l'hébergeur." + }, + "moveUp": "Déplacer {{tier}} vers le haut", + "moveDown": "Déplacer {{tier}} vers le bas" + }, "verifyChecksums": "Vérifier les sommes de contrôle", "verifyChecksumsDesc": "Vérifier l'intégrité du fichier après téléchargement", "preAllocate": "Pré-allouer l'espace", diff --git a/src/layouts/__tests__/AppLayout.test.tsx b/src/layouts/__tests__/AppLayout.test.tsx index 35817e6..b0ea617 100644 --- a/src/layouts/__tests__/AppLayout.test.tsx +++ b/src/layouts/__tests__/AppLayout.test.tsx @@ -40,6 +40,7 @@ const baseConfig: AppConfig = { "vortex-mod-captcha-anticaptcha", "vortex-mod-captcha-browser", ], + resolutionOrder: ["premium", "debrid", "free"], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/stores/__tests__/settingsStore.test.ts b/src/stores/__tests__/settingsStore.test.ts index 98b0d69..ae4a3ac 100644 --- a/src/stores/__tests__/settingsStore.test.ts +++ b/src/stores/__tests__/settingsStore.test.ts @@ -36,6 +36,7 @@ const baseConfig: AppConfig = { "vortex-mod-captcha-anticaptcha", "vortex-mod-captcha-browser", ], + resolutionOrder: ["premium", "debrid", "free"], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/types/settings.ts b/src/types/settings.ts index 9a842bb..2eab4d2 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,4 +1,6 @@ export type ProxyType = "none" | "http" | "socks5"; +/** Mirrors `domain::model::config::ResolutionTier`. */ +export type ResolutionTier = "premium" | "debrid" | "free"; export type ThemeMode = "light" | "dark" | "auto"; export type SettingTab = | "general" @@ -32,6 +34,8 @@ export interface AppConfig { dynamicSplitMinRemainingMb: number; captchaTimeoutSeconds: number; captchaSolverOrder: string[]; + /** Tiers walked by link resolution, most preferred first. PRD §4.3. */ + resolutionOrder: ResolutionTier[]; // History historyRetentionDays: number; diff --git a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx index 56e7334..1106fd9 100644 --- a/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx +++ b/src/views/LinkGrabberView/__tests__/LinkGrabberView.test.tsx @@ -36,6 +36,7 @@ const baseConfig: AppConfig = { "vortex-mod-captcha-anticaptcha", "vortex-mod-captcha-browser", ], + resolutionOrder: ["premium", "debrid", "free"], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", diff --git a/src/views/SettingsView/DownloadsSection.tsx b/src/views/SettingsView/DownloadsSection.tsx index 1f53cd4..dadfae7 100644 --- a/src/views/SettingsView/DownloadsSection.tsx +++ b/src/views/SettingsView/DownloadsSection.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { useTauriMutation } from "@/api/hooks"; import { toast } from "@/lib/toast"; import type { AppConfig, AppConfigPatch } from "@/types/settings"; +import { ResolutionOrderSetting } from "./ResolutionOrderSetting"; import { SettingToggle, SettingNumberInput } from "./SettingField"; interface DownloadsSectionProps { @@ -75,6 +76,11 @@ export function DownloadsSection({ config }: DownloadsSectionProps) { /> + handleChange("resolutionOrder", next)} + /> +
void; +} + +export function ResolutionOrderSetting({ value, onChange }: ResolutionOrderSettingProps) { + const { t } = useTranslation(); + const order = normalizeOrder(value); + + const move = (index: number, offset: -1 | 1) => { + const target = index + offset; + if (target < 0 || target >= order.length) return; + const next = [...order]; + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }; + + return ( +
+
+

{t("settings.downloads.resolutionOrder.label")}

+

+ {t("settings.downloads.resolutionOrder.description")} +

+
+
    + {order.map((tier, index) => { + const label = t(`settings.downloads.resolutionOrder.tiers.${tier}`); + return ( +
  1. + {index + 1} +
    +

    {label}

    +

    + {t(`settings.downloads.resolutionOrder.hints.${tier}`)} +

    +
    + + +
  2. + ); + })} +
+
+ ); +} + +/** Mirrors `domain::model::config::normalize_resolution_order`. */ +function normalizeOrder(raw: readonly ResolutionTier[]): ResolutionTier[] { + const seen = new Set(); + for (const tier of [...raw, ...TIERS]) { + if (TIERS.includes(tier)) seen.add(tier); + } + return [...seen]; +} diff --git a/src/views/SettingsView/__tests__/ResolutionOrderSetting.test.tsx b/src/views/SettingsView/__tests__/ResolutionOrderSetting.test.tsx new file mode 100644 index 0000000..2e43cea --- /dev/null +++ b/src/views/SettingsView/__tests__/ResolutionOrderSetting.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ResolutionOrderSetting } from "../ResolutionOrderSetting"; +import type { ResolutionTier } from "@/types/settings"; + +const DEFAULT_ORDER: ResolutionTier[] = ["premium", "debrid", "free"]; + +function renderSetting(value: ResolutionTier[] = DEFAULT_ORDER) { + const onChange = vi.fn(); + render(); + return { onChange }; +} + +function renderedTiers(): string[] { + return screen.getAllByRole("listitem").map((item) => item.textContent ?? ""); +} + +describe("ResolutionOrderSetting", () => { + it("should list the tiers in the configured order when rendered", () => { + renderSetting(["debrid", "free", "premium"]); + + const tiers = renderedTiers(); + expect(tiers[0]).toContain("Debrid service"); + expect(tiers[1]).toContain("Free"); + expect(tiers[2]).toContain("Premium account"); + }); + + it("should append missing tiers when the persisted order is incomplete", () => { + renderSetting(["debrid"]); + + const tiers = renderedTiers(); + expect(tiers).toHaveLength(3); + expect(tiers[0]).toContain("Debrid service"); + expect(tiers[1]).toContain("Premium account"); + expect(tiers[2]).toContain("Free"); + }); + + it("should promote debrid above premium when its move-up button is pressed", async () => { + const user = userEvent.setup(); + const { onChange } = renderSetting(); + + await user.click(screen.getByRole("button", { name: "Move Debrid service up" })); + + expect(onChange).toHaveBeenCalledWith(["debrid", "premium", "free"]); + }); + + it("should demote premium below debrid when its move-down button is pressed", async () => { + const user = userEvent.setup(); + const { onChange } = renderSetting(); + + await user.click(screen.getByRole("button", { name: "Move Premium account down" })); + + expect(onChange).toHaveBeenCalledWith(["debrid", "premium", "free"]); + }); + + it("should disable the moves that would push a tier off the list", () => { + renderSetting(); + + expect(screen.getByRole("button", { name: "Move Premium account up" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Move Free down" })).toBeDisabled(); + }); +}); diff --git a/src/views/SettingsView/__tests__/Sections.test.tsx b/src/views/SettingsView/__tests__/Sections.test.tsx index 50a440e..2904624 100644 --- a/src/views/SettingsView/__tests__/Sections.test.tsx +++ b/src/views/SettingsView/__tests__/Sections.test.tsx @@ -52,6 +52,7 @@ const mockConfig: AppConfig = { "vortex-mod-captcha-anticaptcha", "vortex-mod-captcha-browser", ], + resolutionOrder: ["premium", "debrid", "free"], historyRetentionDays: 30, proxyType: "none", proxyUrl: null, diff --git a/src/views/SettingsView/__tests__/SettingsView.test.tsx b/src/views/SettingsView/__tests__/SettingsView.test.tsx index bd06b58..7cdb43c 100644 --- a/src/views/SettingsView/__tests__/SettingsView.test.tsx +++ b/src/views/SettingsView/__tests__/SettingsView.test.tsx @@ -41,6 +41,7 @@ const mockConfig: AppConfig = { "vortex-mod-captcha-anticaptcha", "vortex-mod-captcha-browser", ], + resolutionOrder: ["premium", "debrid", "free"], proxyType: "none", proxyUrl: null, userAgent: "Vortex/1.0", From 7752d19b2dedc3ab489c259cbc5d307a0344b445 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:40:21 +0200 Subject: [PATCH 2/4] feat(registry): list vortex-mod-realdebrid and vortex-mod-alldebrid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checksums come from each plugin's CI release SHA256SUMS, not a local build: the WASM artefacts are not byte-reproducible off the runner. Both entries require Vortex 0.3.0, the release that introduces the Premium → Debrid → Free cascade. On an older host a debrid plugin is just another URL claimant and would outrank the hoster plugin that owns the domain. Refs MAT-142 --- registry/registry.toml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/registry/registry.toml b/registry/registry.toml index 8b3e24f..ac8b0ab 100644 --- a/registry/registry.toml +++ b/registry/registry.toml @@ -171,3 +171,31 @@ checksum_sha256 = "28ba16ce5325b48451164b7cd2062b4225f5a87a1f1c2ce7cd7e7211 checksum_sha256_toml = "6d7e0152bc28c56adae29ba0e078cb511cc6aad468d2395c58d02d3b1f758dd6" official = true min_vortex_version = "0.1.0" + +[[plugin]] +name = "vortex-mod-realdebrid" +description = "Real-Debrid — unrestrict a covered hoster link with a premium API token held in the keyring" +author = "vortex-community" +version = "1.0.0" +category = "debrid" +repository = "https://github.com/mpiton/vortex-mod-realdebrid" +checksum_sha256 = "4aece8b0af53812dbbba8c60acb8de4cbe35856311ec64624d923801f3659fb7" +checksum_sha256_toml = "d79bfb33786d76ea20032b5746e558fef6c4a9f0878b930d7d1197696c76e300" +official = true +# Vortex 0.3.0 is where the Premium → Debrid → Free cascade lands. On an +# older host a debrid plugin is just another URL claimant and would outrank +# the hoster plugin that owns the domain. +min_vortex_version = "0.3.0" + +[[plugin]] +name = "vortex-mod-alldebrid" +description = "AllDebrid — unrestrict a covered hoster link with a premium API key held in the keyring" +author = "vortex-community" +version = "1.0.0" +category = "debrid" +repository = "https://github.com/mpiton/vortex-mod-alldebrid" +checksum_sha256 = "3e1ef6417574884c8c19360eccb349ab948c2bc01e671e4c22b0e121228a3003" +checksum_sha256_toml = "c672b883752d1ca2d5864a7fcc01dc26d2c4e132afda36eb1aa67add7bc75d39" +official = true +# Same 0.3.0 floor as vortex-mod-realdebrid above. +min_vortex_version = "0.3.0" From 39d2c148aacd6d15c707f5f2bf0efe2f9f75d952 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:22:34 +0200 Subject: [PATCH 3/4] fix(download): fall through to free when a debrid fails at download time The link check picks a debrid while it is healthy and persists the module and account, but resolution at download time only rotates within that module and HosterNoFile is not rotatable. A quota spent since the check, a hoster dropped from coverage, or an outage therefore left the download in error instead of trying the next configured tier (MAT-142 R-04). Only the free rung is retried, and only for Debrid modules. Premium is deliberately not: any premium account for the hoster was already offered this link at check time and lost to the debrid. CaptchaRequired stays unwrapped so the engine can still answer the challenge; anything else reports both reasons through ResolutionExhausted. Also bounds resolution tier identifiers arriving over IPC, since the parse error quotes what it rejected, and drops the per-call Vec allocation in normalize_resolution_order. --- CHANGELOG.md | 8 + src-tauri/src/adapters/driving/tauri_ipc.rs | 33 ++- .../commands/hoster_download_source.rs | 73 +++++- .../commands/hoster_download_source_tests.rs | 230 ++++++++++++++++++ .../commands/resolve_links_tiers_tests.rs | 23 +- src-tauri/src/domain/error.rs | 15 ++ src-tauri/src/domain/model/config.rs | 16 +- 7 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 src-tauri/src/application/commands/hoster_download_source_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d284469..9ce8b56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A debrid that fails when the engine asks for the direct URL now falls through + to anonymous extraction instead of leaving the download in error. The link + check picks the debrid while it is healthy, but the quota can be spent, the + hoster can drop out of coverage, or the service can go down before the + transfer starts. When neither rung delivers, the error names both (MAT-142). +- Resolution tier identifiers coming over IPC are now length-bounded, so a + malformed patch cannot turn the parse error into an oversized IPC string + (MAT-142). - CAPTCHA browser windows now close directly from persisted terminal command flows instead of relying on a lossy event subscriber (MAT-141). - The Windows Tesseract broker regression fixture now returns success after diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index ff30e50..1aee43d 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -1413,6 +1413,10 @@ const MAX_CAPTCHA_SOLVER_IDENTIFIER_BYTES: usize = 128; /// One entry per `ResolutionTier` variant; duplicates are dropped by /// `normalize_resolution_order`, so a longer list is malformed input. const MAX_RESOLUTION_ORDER_ENTRIES: usize = 3; +/// The longest tier name is `premium`. Anything beyond this is malformed +/// input whose only effect would be an oversized IPC error string, since +/// the parse failure quotes what it rejected. +const MAX_RESOLUTION_TIER_IDENTIFIER_BYTES: usize = 16; #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -1502,10 +1506,18 @@ impl TryFrom for ConfigPatch { Some(raw) => Some(raw.parse().map_err(|e: DomainError| e.to_string())?), None => None, }; - let resolution_order = match &d.resolution_order { - Some(order) if order.len() > MAX_RESOLUTION_ORDER_ENTRIES => { + if let Some(order) = &d.resolution_order { + if order.len() > MAX_RESOLUTION_ORDER_ENTRIES { return Err("Resolution order exceeds safety limits".to_string()); } + if order + .iter() + .any(|tier| tier.len() > MAX_RESOLUTION_TIER_IDENTIFIER_BYTES) + { + return Err("Resolution tier identifier exceeds safety limits".to_string()); + } + } + let resolution_order = match &d.resolution_order { Some(order) => Some( order .iter() @@ -5007,6 +5019,23 @@ mod tests { ); } + #[test] + fn config_patch_dto_rejects_an_oversized_resolution_tier_identifier() { + use super::{ConfigPatch, ConfigPatchDto}; + + // The parse error quotes what it rejected, so an unbounded tier + // name would come straight back as an oversized IPC error. + let dto = ConfigPatchDto { + resolution_order: Some(vec!["x".repeat(4096)]), + ..Default::default() + }; + + let result: Result = dto.try_into(); + let error = result.expect_err("oversized tier identifier must be rejected"); + assert!(error.contains("tier identifier"), "{error}"); + assert!(!error.contains("xxxx"), "{error}"); + } + #[test] fn captcha_browser_window_is_bound_to_its_challenge() { let own_id = crate::domain::model::captcha::CaptchaId::new("captcha-1"); diff --git a/src-tauri/src/application/commands/hoster_download_source.rs b/src-tauri/src/application/commands/hoster_download_source.rs index 66400af..dba3157 100644 --- a/src-tauri/src/application/commands/hoster_download_source.rs +++ b/src-tauri/src/application/commands/hoster_download_source.rs @@ -3,7 +3,9 @@ use super::ResolveHosterSourceHandler; use crate::application::services::download_source_policy::classify_download_module; use crate::domain::error::DomainError; +use crate::domain::model::config::ResolutionTier; use crate::domain::model::download::Download; +use crate::domain::model::plugin::PluginCategory; use crate::domain::ports::driven::{ DownloadSourceResolver, ExtractedHosterLink, ResolutionCancellation, ResolvedDownloadSource, }; @@ -55,7 +57,14 @@ impl ResolveHosterSourceHandler { cancellation: &ResolutionCancellation, ) -> Result { if download.account_id().is_some() { - return self.resolve_download(download, cancellation); + let error = match self.resolve_download(download, cancellation) { + Ok(source) => return Ok(source), + Err(error) => error, + }; + if self.debrid_falls_through(download, cancellation) { + return self.free_tier_source(download, error); + } + return Err(error); } cancellation.ensure_active()?; let service_name = download.module_name().ok_or_else(|| { @@ -67,4 +76,66 @@ impl ResolveHosterSourceHandler { cancellation.ensure_active()?; resolved_protected_source(link) } + + /// A debrid is one rung of the cascade, not the only route to the file. + /// The link check picked it while it was healthy; by the time the engine + /// asks for a URL the quota can be spent, the hoster dropped out of + /// coverage, or the service down. R-04 wants the next rung tried with an + /// explicit reason instead of a dead download. + /// + /// Only the free rung is retried. Premium is deliberately not: any + /// premium account for the hoster was already offered this link at check + /// time and lost to the debrid, so re-offering it replays a decision. + /// A lookup that itself fails leaves the fall-through unproven, and then + /// the debrid error stands. + fn debrid_falls_through( + &self, + download: &Download, + cancellation: &ResolutionCancellation, + ) -> bool { + let Some(module) = download.module_name() else { + return false; + }; + cancellation.ensure_active().is_ok() + && self + .config + .get_config() + .is_ok_and(|config| config.resolution_order.contains(&ResolutionTier::Free)) + && self.plugins.list_loaded().is_ok_and(|infos| { + infos + .iter() + .any(|info| info.name() == module && info.category() == PluginCategory::Debrid) + }) + } + + /// Anonymous extraction through the plugin that owns the URL, keeping the + /// debrid's reason alongside the free one when neither rung delivers. + fn free_tier_source( + &self, + download: &Download, + debrid_error: DomainError, + ) -> Result { + let url = download.url().as_str(); + let hoster = match self.plugins.resolve_url(url) { + Ok(Some(info)) if Some(info.name()) != download.module_name() => { + info.name().to_string() + } + _ => return Err(debrid_error), + }; + self.plugins + .extract_hoster_link(&hoster, url, None) + .and_then(resolved_protected_source) + .map_err(|free_error| match free_error { + // The engine answers a challenge; burying it in a text + // summary would strand the download instead. + captcha @ DomainError::CaptchaRequired { .. } => captcha, + free_error => DomainError::ResolutionExhausted(format!( + "debrid: {debrid_error}; free: {free_error}" + )), + }) + } } + +#[cfg(test)] +#[path = "hoster_download_source_tests.rs"] +mod tests; diff --git a/src-tauri/src/application/commands/hoster_download_source_tests.rs b/src-tauri/src/application/commands/hoster_download_source_tests.rs new file mode 100644 index 0000000..dd1877a --- /dev/null +++ b/src-tauri/src/application/commands/hoster_download_source_tests.rs @@ -0,0 +1,230 @@ +//! The debrid rung can still fail after the link check accepted it, and +//! R-04 wants the next rung tried rather than a dead download. + +use std::sync::{Arc, Mutex}; + +use super::*; +use crate::application::commands::tests_support::{ + CapturingEventBus, FakeAccountCredentialStore, InMemoryAccountRepo, InMemoryDownloadRepo, +}; +use crate::application::services::account_operation_locks::AccountOperationLocks; +use crate::application::services::{AccountRotator, AccountSelector}; +use crate::domain::model::account::{Account, AccountId, AccountStatus, AccountType}; +use crate::domain::model::config::{AppConfig, ConfigPatch}; +use crate::domain::model::credential::Credential; +use crate::domain::model::download::{DownloadId, Url}; +use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::ports::driven::{ + AccountCredentialStore, AccountRepository, Clock, ConfigStore, PluginLoader, +}; + +const DEBRID: &str = "vortex-mod-alldebrid"; +const HOSTER: &str = "vortex-mod-mediafire"; +const URL: &str = "https://www.mediafire.com/file/abc/archive.zip/file"; + +struct FixedClock; + +impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + 1_700_000_000 + } +} + +/// The default order, so the free rung stays configured. +struct DefaultConfig; + +impl ConfigStore for DefaultConfig { + fn get_config(&self) -> Result { + Ok(AppConfig::default()) + } + + fn update_config(&self, _: ConfigPatch) -> Result { + Ok(AppConfig::default()) + } +} + +/// Every account-backed rung fails; the anonymous one serves the file +/// unless the hoster is down too. +struct FallThroughLoader { + hoster_serves: bool, + anonymous_calls: Mutex>, +} + +impl FallThroughLoader { + fn new(hoster_serves: bool) -> Self { + Self { + hoster_serves, + anonymous_calls: Mutex::new(Vec::new()), + } + } + + fn info(name: &str) -> PluginInfo { + let category = if name == DEBRID { + PluginCategory::Debrid + } else { + PluginCategory::Hoster + }; + PluginInfo::new( + name.to_string(), + "1.0.0".into(), + name.to_string(), + "vortex".into(), + category, + ) + } +} + +impl PluginLoader for FallThroughLoader { + fn load(&self, _: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + + fn unload(&self, _: &str) -> Result<(), DomainError> { + Ok(()) + } + + fn resolve_url(&self, _: &str) -> Result, DomainError> { + Ok(Some(Self::info(HOSTER))) + } + + fn plugin_can_handle(&self, _: &str, _: &str) -> Result { + Ok(true) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![Self::info(HOSTER), Self::info(DEBRID)]) + } + + fn set_enabled(&self, _: &str, _: bool) -> Result<(), DomainError> { + Ok(()) + } + + fn extract_hoster_link( + &self, + service_name: &str, + url: &str, + credential: Option<&Credential>, + ) -> Result { + if credential.is_some() { + return Err(DomainError::AccountQuotaExceeded); + } + self.anonymous_calls + .lock() + .expect("call log") + .push(service_name.to_string()); + if !self.hoster_serves { + return Err(DomainError::HosterNoFile); + } + Ok(ExtractedHosterLink { + source_url: url.to_string(), + filename: Some("archive.zip".into()), + size_bytes: Some(42), + direct_url: Some("https://cdn.example/archive.zip".into()), + resumable: Some(true), + request_headers: Vec::new(), + traffic_used_bytes: None, + traffic_total_bytes: None, + captcha: None, + }) + } +} + +fn seed( + repo: &InMemoryAccountRepo, + credentials: &FakeAccountCredentialStore, + id: &str, + service: &str, +) { + let mut account = Account::new( + AccountId::new(id), + service.to_string(), + "user".into(), + AccountType::Debrid, + 0, + ); + account.set_status(AccountStatus::Valid); + repo.save(&account).expect("seed account"); + credentials + .store_password(account.id(), "secret") + .expect("seed credential"); +} + +fn handler(loader: Arc) -> ResolveHosterSourceHandler { + let repo = Arc::new(InMemoryAccountRepo::new()); + let credentials = Arc::new(FakeAccountCredentialStore::new()); + seed(&repo, &credentials, "debrid-1", DEBRID); + seed(&repo, &credentials, "premium-1", HOSTER); + let events = Arc::new(CapturingEventBus::new()); + let clock: Arc = Arc::new(FixedClock); + let repo: Arc = repo; + let selector = AccountSelector::new(repo.clone(), events.clone(), clock.clone()); + let rotator = AccountRotator::new(selector, repo.clone(), events.clone(), clock.clone()); + ResolveHosterSourceHandler::new( + repo, + credentials, + loader, + events, + clock, + Arc::new(AccountOperationLocks::default()), + Arc::new(InMemoryDownloadRepo::new()), + Arc::new(DefaultConfig), + rotator, + ) +} + +fn download(module: &str, account_id: &str) -> Download { + Download::new( + DownloadId(1), + Url::new(URL).expect("valid url"), + "archive.zip".into(), + "/tmp/archive.zip".into(), + ) + .with_module_name(module.to_string()) + .with_account_id(AccountId::new(account_id)) +} + +#[test] +fn test_a_spent_debrid_quota_falls_through_to_anonymous_extraction() { + let loader = Arc::new(FallThroughLoader::new(true)); + let handler = handler(loader.clone()); + + let source = handler + .resolve(&download(DEBRID, "debrid-1")) + .expect("the free rung still owns the URL"); + + assert_eq!(source.request_url(), "https://cdn.example/archive.zip"); + assert_eq!(*loader.anonymous_calls.lock().expect("call log"), [HOSTER]); +} + +#[test] +fn test_both_rungs_failing_reports_both_reasons_and_never_a_direct_url() { + let loader = Arc::new(FallThroughLoader::new(false)); + let handler = handler(loader.clone()); + + let error = handler + .resolve(&download(DEBRID, "debrid-1")) + .expect_err("no rung produced a file"); + + let message = error.to_string(); + assert!(message.contains("debrid:"), "{message}"); + assert!(message.contains("free:"), "{message}"); +} + +#[test] +fn test_a_failed_premium_hoster_is_never_downgraded_to_the_free_path() { + // Only the debrid rung falls through. Retrying a paid hoster account + // anonymously would hand the user a throttled free download while + // reporting success. + let loader = Arc::new(FallThroughLoader::new(true)); + let handler = handler(loader.clone()); + + let error = handler + .resolve(&download(HOSTER, "premium-1")) + .expect_err("the premium failure stands"); + + assert!( + matches!(error, DomainError::AccountQuotaExceeded), + "{error}" + ); + assert!(loader.anonymous_calls.lock().expect("call log").is_empty()); +} diff --git a/src-tauri/src/application/commands/resolve_links_tiers_tests.rs b/src-tauri/src/application/commands/resolve_links_tiers_tests.rs index f7cb739..43e8bfc 100644 --- a/src-tauri/src/application/commands/resolve_links_tiers_tests.rs +++ b/src-tauri/src/application/commands/resolve_links_tiers_tests.rs @@ -116,11 +116,16 @@ impl ConfigStore for OrderedConfigStore { } fn account(id: &str, service: &str, status: AccountStatus) -> Account { + let account_type = if service == DEBRID { + AccountType::Debrid + } else { + AccountType::Premium + }; let mut account = Account::new( AccountId::new(id), service.to_string(), "user".into(), - AccountType::Debrid, + account_type, 0, ); account.set_status(status); @@ -228,6 +233,22 @@ async fn test_exhausted_debrid_account_falls_through_to_free() { assert_eq!(*plugins.anonymous_calls.lock().expect("call log"), [HOSTER]); } +#[tokio::test] +async fn test_an_exhausted_premium_account_aborts_instead_of_downgrading_to_free() { + // The rotator's contract is that the caller waits out the cooldown, so + // premium exhaustion is not a skip. Downgrading here would quietly turn + // a paid download into a throttled anonymous one. + let mut premium = account("premium-1", HOSTER, AccountStatus::Valid); + // A deadline no clock reaches: the account is in cooldown, not absent, + // which is what separates exhaustion from "no account configured". + premium.mark_exhausted(u64::MAX); + + let (resolved, plugins) = resolve(default_resolution_order(), vec![premium], true).await; + + assert_eq!(resolved[0].status, "error"); + assert!(plugins.anonymous_calls.lock().expect("call log").is_empty()); +} + #[tokio::test] async fn test_cascade_with_no_usable_tier_reports_every_rung_it_tried() { let (resolved, plugins) = resolve( diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index e8f8a5a..1ba0ce5 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -248,6 +248,21 @@ mod tests { assert!(msg.contains("def")); } + #[test] + fn test_display_resolution_exhausted_keeps_every_tier_reason() { + // R-04: the user needs to read which rung refused and why, not a + // generic failure that hides the whole cascade. + let err = DomainError::ResolutionExhausted( + "premium: no premium account for vortex-mod-mediafire; \ + debrid: no debrid service covers this hoster" + .to_string(), + ); + let msg = err.to_string(); + + assert!(msg.contains("no premium account"), "{msg}"); + assert!(msg.contains("no debrid service covers"), "{msg}"); + } + #[test] fn test_display_unsupported_checksum_format() { let err = DomainError::UnsupportedChecksumFormat("xyz".to_string()); diff --git a/src-tauri/src/domain/model/config.rs b/src-tauri/src/domain/model/config.rs index ca685fa..2abe8e6 100644 --- a/src-tauri/src/domain/model/config.rs +++ b/src-tauri/src/domain/model/config.rs @@ -275,20 +275,22 @@ impl FromStr for ResolutionTier { /// PRD §4.3 default: a hoster-specific premium account outranks a debrid /// subscription, which outranks anonymous free extraction. +const DEFAULT_ORDER: [ResolutionTier; 3] = [ + ResolutionTier::Premium, + ResolutionTier::Debrid, + ResolutionTier::Free, +]; + pub fn default_resolution_order() -> Vec { - vec![ - ResolutionTier::Premium, - ResolutionTier::Debrid, - ResolutionTier::Free, - ] + DEFAULT_ORDER.to_vec() } /// Drop duplicates from a persisted order and append any tier the user /// left out, so every rung stays reachable and no link becomes /// unresolvable through a hand-edited config. pub fn normalize_resolution_order(raw: &[ResolutionTier]) -> Vec { - let mut normalized: Vec = Vec::with_capacity(3); - for tier in raw.iter().chain(default_resolution_order().iter()) { + let mut normalized: Vec = Vec::with_capacity(DEFAULT_ORDER.len()); + for tier in raw.iter().chain(DEFAULT_ORDER.iter()) { if !normalized.contains(tier) { normalized.push(*tier); } From f9f0ceced7ae0536d6ef0eceb79bd6120728d7ef Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:49:16 +0200 Subject: [PATCH 4/4] fix(download): only fall through to a hoster, never to a second debrid resolve_url keeps debrid candidates and merely orders them last, so for a URL no hoster claims the runner-up is another debrid. The same-module guard let that through: the fall-through called it without credentials and labelled the refusal "free", naming a rung that was never tried. Requires PluginCategory::Hoster on the resolved plugin, so the debrid's own error stands when no free rung covers the URL. --- CHANGELOG.md | 4 ++- .../commands/hoster_download_source.rs | 9 ++++- .../commands/hoster_download_source_tests.rs | 36 +++++++++++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce8b56..34678b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 to anonymous extraction instead of leaving the download in error. The link check picks the debrid while it is healthy, but the quota can be spent, the hoster can drop out of coverage, or the service can go down before the - transfer starts. When neither rung delivers, the error names both (MAT-142). + transfer starts. When neither rung delivers, the error names both. The + fall-through only ever targets a hoster plugin, so a second debrid is never + called anonymously and reported as the free rung (MAT-142). - Resolution tier identifiers coming over IPC are now length-bounded, so a malformed patch cannot turn the parse error into an oversized IPC string (MAT-142). diff --git a/src-tauri/src/application/commands/hoster_download_source.rs b/src-tauri/src/application/commands/hoster_download_source.rs index dba3157..df8705f 100644 --- a/src-tauri/src/application/commands/hoster_download_source.rs +++ b/src-tauri/src/application/commands/hoster_download_source.rs @@ -116,8 +116,15 @@ impl ResolveHosterSourceHandler { debrid_error: DomainError, ) -> Result { let url = download.url().as_str(); + // `resolve_url` keeps debrid candidates and merely orders them last, + // so the runner-up for a URL no hoster claims is another debrid. + // Calling it without credentials is not a free extraction, and + // reporting its refusal as "free" would name a rung never tried. let hoster = match self.plugins.resolve_url(url) { - Ok(Some(info)) if Some(info.name()) != download.module_name() => { + Ok(Some(info)) + if info.category() == PluginCategory::Hoster + && Some(info.name()) != download.module_name() => + { info.name().to_string() } _ => return Err(debrid_error), diff --git a/src-tauri/src/application/commands/hoster_download_source_tests.rs b/src-tauri/src/application/commands/hoster_download_source_tests.rs index dd1877a..e043b2a 100644 --- a/src-tauri/src/application/commands/hoster_download_source_tests.rs +++ b/src-tauri/src/application/commands/hoster_download_source_tests.rs @@ -19,6 +19,7 @@ use crate::domain::ports::driven::{ }; const DEBRID: &str = "vortex-mod-alldebrid"; +const OTHER_DEBRID: &str = "vortex-mod-realdebrid"; const HOSTER: &str = "vortex-mod-mediafire"; const URL: &str = "https://www.mediafire.com/file/abc/archive.zip/file"; @@ -47,6 +48,7 @@ impl ConfigStore for DefaultConfig { /// unless the hoster is down too. struct FallThroughLoader { hoster_serves: bool, + resolves_to: &'static str, anonymous_calls: Mutex>, } @@ -54,12 +56,22 @@ impl FallThroughLoader { fn new(hoster_serves: bool) -> Self { Self { hoster_serves, + resolves_to: HOSTER, anonymous_calls: Mutex::new(Vec::new()), } } + /// The plugin `resolve_url` hands back for the URL, which is not always + /// a hoster: debrids stay in the candidate list, ordered last. + fn resolving_to(name: &'static str) -> Self { + Self { + resolves_to: name, + ..Self::new(true) + } + } + fn info(name: &str) -> PluginInfo { - let category = if name == DEBRID { + let category = if name == DEBRID || name == OTHER_DEBRID { PluginCategory::Debrid } else { PluginCategory::Hoster @@ -84,7 +96,7 @@ impl PluginLoader for FallThroughLoader { } fn resolve_url(&self, _: &str) -> Result, DomainError> { - Ok(Some(Self::info(HOSTER))) + Ok(Some(Self::info(self.resolves_to))) } fn plugin_can_handle(&self, _: &str, _: &str) -> Result { @@ -210,6 +222,26 @@ fn test_both_rungs_failing_reports_both_reasons_and_never_a_direct_url() { assert!(message.contains("free:"), "{message}"); } +#[test] +fn test_a_second_debrid_is_never_mistaken_for_the_free_rung() { + // With no hoster plugin loaded for the URL, the runner-up `resolve_url` + // offers is the other debrid. Calling it without credentials is not a + // free extraction, so the debrid's own error has to stand rather than a + // combined one naming a rung that was never tried. + let loader = Arc::new(FallThroughLoader::resolving_to(OTHER_DEBRID)); + let handler = handler(loader.clone()); + + let error = handler + .resolve(&download(DEBRID, "debrid-1")) + .expect_err("no free rung covers this URL"); + + assert!( + matches!(error, DomainError::AccountQuotaExceeded), + "{error}" + ); + assert!(loader.anonymous_calls.lock().expect("call log").is_empty()); +} + #[test] fn test_a_failed_premium_hoster_is_never_downgraded_to_the_free_path() { // Only the debrid rung falls through. Retrying a paid hoster account