diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd126d..34678b7 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 @@ -26,6 +34,16 @@ 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. 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). - 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/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" 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..1aee43d 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,13 @@ 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; +/// 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")] @@ -1438,6 +1453,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 +1506,27 @@ impl TryFrom for ConfigPatch { Some(raw) => Some(raw.parse().map_err(|e: DomainError| e.to_string())?), None => None, }; + 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() + .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 +1549,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, @@ -4979,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..df8705f 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,73 @@ 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(); + // `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 info.category() == PluginCategory::Hoster + && 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..e043b2a --- /dev/null +++ b/src-tauri/src/application/commands/hoster_download_source_tests.rs @@ -0,0 +1,262 @@ +//! 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 OTHER_DEBRID: &str = "vortex-mod-realdebrid"; +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, + resolves_to: &'static str, + anonymous_calls: Mutex>, +} + +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 || name == OTHER_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(self.resolves_to))) + } + + 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_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 + // 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/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..43e8bfc --- /dev/null +++ b/src-tauri/src/application/commands/resolve_links_tiers_tests.rs @@ -0,0 +1,269 @@ +//! 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 account_type = if service == DEBRID { + AccountType::Debrid + } else { + AccountType::Premium + }; + let mut account = Account::new( + AccountId::new(id), + service.to_string(), + "user".into(), + account_type, + 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_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( + 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..1ba0ce5 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, @@ -241,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 18292ee..2abe8e6 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,70 @@ 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. +const DEFAULT_ORDER: [ResolutionTier; 3] = [ + ResolutionTier::Premium, + ResolutionTier::Debrid, + ResolutionTier::Free, +]; + +pub fn default_resolution_order() -> Vec { + 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(DEFAULT_ORDER.len()); + for tier in raw.iter().chain(DEFAULT_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 +351,7 @@ pub struct ConfigPatch { // Accounts pub account_selection_strategy: Option, + pub resolution_order: Option>, // Network pub proxy_type: Option, @@ -415,6 +488,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 +775,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",