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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Comment thread
mpiton marked this conversation as resolved.
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
Expand Down
28 changes: 28 additions & 0 deletions registry/registry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 19 additions & 2 deletions src-tauri/src/adapters/driven/config/toml_config_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -172,6 +173,7 @@ struct ConfigDto {

// Accounts
account_selection_strategy: String,
resolution_order: Vec<String>,

// Network
proxy_type: String,
Expand Down Expand Up @@ -234,6 +236,11 @@ impl From<AppConfig> 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,
Expand Down Expand Up @@ -272,6 +279,15 @@ impl TryFrom<ConfigDto> 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::<Result<Vec<ResolutionTier>, DomainError>>()?,
);
Ok(Self {
download_dir: d.download_dir,
start_minimized: d.start_minimized,
Expand All @@ -296,6 +312,7 @@ impl TryFrom<ConfigDto> 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,
Expand Down
23 changes: 22 additions & 1 deletion src-tauri/src/adapters/driven/plugin/extism_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for info in infos {
let name = info.name().to_string();
match self.registry.call_plugin(&name, "can_handle", url) {
Expand All @@ -488,6 +491,24 @@ impl PluginLoader for ExtismPluginLoader {
Ok(None)
}

fn plugin_can_handle(&self, name: &str, url: &str) -> Result<bool, DomainError> {
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<Vec<PluginInfo>, DomainError> {
Ok(self.registry.list_info())
}
Expand Down
59 changes: 58 additions & 1 deletion src-tauri/src/adapters/driving/tauri_ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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<String>,

// Network
pub proxy_type: String,
Expand Down Expand Up @@ -1377,6 +1380,11 @@ impl From<AppConfig> 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,
Expand All @@ -1402,6 +1410,13 @@ impl From<AppConfig> 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")]
Expand Down Expand Up @@ -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<String>,
/// Accepted values: `"premium"`, `"debrid"`, `"free"`. Unknown values
/// are rejected by `ConfigPatch::try_from(ConfigPatchDto)`.
pub resolution_order: Option<Vec<String>>,

// Network
pub proxy_type: Option<String>,
Expand Down Expand Up @@ -1488,6 +1506,27 @@ impl TryFrom<ConfigPatchDto> 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::<Result<Vec<ResolutionTier>, DomainError>>()
.map_err(|e| e.to_string())?,
),
None => None,
};
Ok(Self {
download_dir: d.download_dir,
start_minimized: d.start_minimized,
Expand All @@ -1510,6 +1549,7 @@ impl TryFrom<ConfigPatchDto> 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,
Expand Down Expand Up @@ -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<ConfigPatch, String> = 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");
Expand Down
80 changes: 79 additions & 1 deletion src-tauri/src/application/commands/hoster_download_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -55,7 +57,14 @@ impl ResolveHosterSourceHandler {
cancellation: &ResolutionCancellation,
) -> Result<ResolvedDownloadSource, DomainError> {
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(|| {
Expand All @@ -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<ResolvedDownloadSource, DomainError> {
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}"
)),
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[cfg(test)]
#[path = "hoster_download_source_tests.rs"]
mod tests;
Loading
Loading