Skip to content

Commit 91ca8af

Browse files
authored
feat: résolution Premium → Debrid → Free et plugins Real-Debrid / AllDebrid (MAT-142) (#183)
* feat(resolution): walk Premium → Debrid → Free before contacting a hoster 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 * feat(registry): list vortex-mod-realdebrid and vortex-mod-alldebrid 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 * 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. * 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.
1 parent e4f01c9 commit 91ca8af

28 files changed

Lines changed: 1335 additions & 35 deletions

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- CAPTCHA solvers: configurable OCR → AntiCaptcha → browser cascade, typed
1616
Tesseract host broker, keyring-backed AntiCaptcha credentials, persisted
1717
per-solver attempts, and a dedicated human-assisted WebView (MAT-141).
18+
- Debrid support: `vortex-mod-realdebrid` and `vortex-mod-alldebrid` plugins
19+
unrestrict a covered hoster link through a keyring-held API token and report
20+
premium expiry back to the Accounts view (MAT-142).
21+
- Configurable Premium → Debrid → Free resolution order (PRD-v2 §4.3). Link
22+
resolution now walks the tiers in the order set under Settings → Downloads,
23+
picking the plugin and account before any hoster is contacted. A tier that
24+
declines records why, so an exhausted cascade names every rung it tried
25+
instead of failing as a bare "no source" (MAT-142).
1826

1927
### Security
2028

@@ -26,6 +34,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2634

2735
### Fixed
2836

37+
- A debrid that fails when the engine asks for the direct URL now falls through
38+
to anonymous extraction instead of leaving the download in error. The link
39+
check picks the debrid while it is healthy, but the quota can be spent, the
40+
hoster can drop out of coverage, or the service can go down before the
41+
transfer starts. When neither rung delivers, the error names both. The
42+
fall-through only ever targets a hoster plugin, so a second debrid is never
43+
called anonymously and reported as the free rung (MAT-142).
44+
- Resolution tier identifiers coming over IPC are now length-bounded, so a
45+
malformed patch cannot turn the parse error into an oversized IPC string
46+
(MAT-142).
2947
- CAPTCHA browser windows now close directly from persisted terminal command
3048
flows instead of relying on a lossy event subscriber (MAT-141).
3149
- The Windows Tesseract broker regression fixture now returns success after

registry/registry.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,31 @@ checksum_sha256 = "28ba16ce5325b48451164b7cd2062b4225f5a87a1f1c2ce7cd7e7211
171171
checksum_sha256_toml = "6d7e0152bc28c56adae29ba0e078cb511cc6aad468d2395c58d02d3b1f758dd6"
172172
official = true
173173
min_vortex_version = "0.1.0"
174+
175+
[[plugin]]
176+
name = "vortex-mod-realdebrid"
177+
description = "Real-Debrid — unrestrict a covered hoster link with a premium API token held in the keyring"
178+
author = "vortex-community"
179+
version = "1.0.0"
180+
category = "debrid"
181+
repository = "https://github.com/mpiton/vortex-mod-realdebrid"
182+
checksum_sha256 = "4aece8b0af53812dbbba8c60acb8de4cbe35856311ec64624d923801f3659fb7"
183+
checksum_sha256_toml = "d79bfb33786d76ea20032b5746e558fef6c4a9f0878b930d7d1197696c76e300"
184+
official = true
185+
# Vortex 0.3.0 is where the Premium → Debrid → Free cascade lands. On an
186+
# older host a debrid plugin is just another URL claimant and would outrank
187+
# the hoster plugin that owns the domain.
188+
min_vortex_version = "0.3.0"
189+
190+
[[plugin]]
191+
name = "vortex-mod-alldebrid"
192+
description = "AllDebrid — unrestrict a covered hoster link with a premium API key held in the keyring"
193+
author = "vortex-community"
194+
version = "1.0.0"
195+
category = "debrid"
196+
repository = "https://github.com/mpiton/vortex-mod-alldebrid"
197+
checksum_sha256 = "3e1ef6417574884c8c19360eccb349ab948c2bc01e671e4c22b0e121228a3003"
198+
checksum_sha256_toml = "c672b883752d1ca2d5864a7fcc01dc26d2c4e132afda36eb1aa67add7bc75d39"
199+
official = true
200+
# Same 0.3.0 floor as vortex-mod-realdebrid above.
201+
min_vortex_version = "0.3.0"

src-tauri/src/adapters/driven/config/toml_config_store.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use std::sync::Mutex;
99
use crate::domain::error::DomainError;
1010
use crate::domain::model::account::AccountSelectionStrategy;
1111
use crate::domain::model::config::{
12-
AppConfig, ConfigPatch, MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS, apply_patch,
13-
normalize_captcha_solver_order, normalize_history_retention_days,
12+
AppConfig, ConfigPatch, MAX_CAPTCHA_TIMEOUT_SECONDS, MIN_CAPTCHA_TIMEOUT_SECONDS,
13+
ResolutionTier, apply_patch, normalize_captcha_solver_order, normalize_history_retention_days,
14+
normalize_resolution_order,
1415
};
1516
use crate::domain::ports::driven::ConfigStore;
1617

@@ -172,6 +173,7 @@ struct ConfigDto {
172173

173174
// Accounts
174175
account_selection_strategy: String,
176+
resolution_order: Vec<String>,
175177

176178
// Network
177179
proxy_type: String,
@@ -234,6 +236,11 @@ impl From<AppConfig> for ConfigDto {
234236
captcha_solver_order: c.captcha_solver_order,
235237
history_retention_days: c.history_retention_days,
236238
account_selection_strategy: c.account_selection_strategy.to_string(),
239+
resolution_order: c
240+
.resolution_order
241+
.iter()
242+
.map(ResolutionTier::to_string)
243+
.collect(),
237244
proxy_type: c.proxy_type,
238245
proxy_url: c.proxy_url,
239246
user_agent: c.user_agent,
@@ -272,6 +279,15 @@ impl TryFrom<ConfigDto> for AppConfig {
272279
} else {
273280
d.account_selection_strategy.parse()?
274281
};
282+
// Same backward-compat contract: an absent list means "never
283+
// configured" and falls back to the PRD default, while a typo'd
284+
// tier is corruption and must not silently disable a rung.
285+
let resolution_order = normalize_resolution_order(
286+
&d.resolution_order
287+
.iter()
288+
.map(|tier| tier.parse())
289+
.collect::<Result<Vec<ResolutionTier>, DomainError>>()?,
290+
);
275291
Ok(Self {
276292
download_dir: d.download_dir,
277293
start_minimized: d.start_minimized,
@@ -296,6 +312,7 @@ impl TryFrom<ConfigDto> for AppConfig {
296312
captcha_solver_order: normalize_captcha_solver_order(&d.captcha_solver_order),
297313
history_retention_days: normalize_history_retention_days(d.history_retention_days),
298314
account_selection_strategy,
315+
resolution_order,
299316
proxy_type: d.proxy_type,
300317
proxy_url: d.proxy_url,
301318
user_agent: d.user_agent,

src-tauri/src/adapters/driven/plugin/extism_loader.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,10 @@ impl PluginLoader for ExtismPluginLoader {
470470
.into_iter()
471471
.filter(|i| i.is_enabled())
472472
.collect();
473-
infos.sort_by(|a, b| a.name().cmp(b.name()));
473+
// A debrid plugin claims every hoster it can unrestrict, so on name
474+
// order alone it would steal URLs the hoster plugin owns. Debrid is a
475+
// fallback rung of the resolution cascade, never the URL owner.
476+
infos.sort_by_key(|i| (i.category() == PluginCategory::Debrid, i.name().to_string()));
474477
for info in infos {
475478
let name = info.name().to_string();
476479
match self.registry.call_plugin(&name, "can_handle", url) {
@@ -488,6 +491,24 @@ impl PluginLoader for ExtismPluginLoader {
488491
Ok(None)
489492
}
490493

494+
fn plugin_can_handle(&self, name: &str, url: &str) -> Result<bool, DomainError> {
495+
let enabled = self
496+
.registry
497+
.list_info()
498+
.into_iter()
499+
.any(|i| i.name() == name && i.is_enabled());
500+
if !enabled {
501+
return Ok(false);
502+
}
503+
match self.registry.call_plugin(name, "can_handle", url) {
504+
Ok(result) => Ok(result.trim() == "true"),
505+
Err(e) => {
506+
tracing::warn!("plugin '{name}' failed can_handle call: {e}");
507+
Ok(false)
508+
}
509+
}
510+
}
511+
491512
fn list_loaded(&self) -> Result<Vec<PluginInfo>, DomainError> {
492513
Ok(self.registry.list_info())
493514
}

src-tauri/src/adapters/driving/tauri_ipc.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ use crate::application::read_models::stats_view::{ModuleStatsDto, StatsViewDto};
7676
use crate::domain::error::DomainError;
7777
use crate::domain::model::account::{AccountId, AccountType};
7878
use crate::domain::model::captcha::{CaptchaId, MAX_CAPTCHA_SOLUTION_BYTES};
79-
use crate::domain::model::config::{AppConfig, ConfigPatch};
79+
use crate::domain::model::config::{AppConfig, ConfigPatch, ResolutionTier};
8080
use crate::domain::model::download::{DownloadId, DownloadState};
8181
use crate::domain::model::package::{PackageId, PackageSourceType};
8282
use crate::domain::model::views::{
@@ -1316,6 +1316,9 @@ pub struct SettingsDto {
13161316
/// Serialized as `"best_traffic" | "round_robin" | "manual"` to mirror
13171317
/// the snake_case enum convention used elsewhere in IPC payloads.
13181318
pub account_selection_strategy: String,
1319+
/// Link resolution cascade, most-preferred first. Entries are
1320+
/// `"premium" | "debrid" | "free"`.
1321+
pub resolution_order: Vec<String>,
13191322

13201323
// Network
13211324
pub proxy_type: String,
@@ -1377,6 +1380,11 @@ impl From<AppConfig> for SettingsDto {
13771380
captcha_solver_order: c.captcha_solver_order,
13781381
history_retention_days: c.history_retention_days,
13791382
account_selection_strategy: c.account_selection_strategy.to_string(),
1383+
resolution_order: c
1384+
.resolution_order
1385+
.iter()
1386+
.map(ResolutionTier::to_string)
1387+
.collect(),
13801388
proxy_type: c.proxy_type,
13811389
proxy_url: c.proxy_url,
13821390
user_agent: c.user_agent,
@@ -1402,6 +1410,13 @@ impl From<AppConfig> for SettingsDto {
14021410

14031411
const MAX_CAPTCHA_SOLVER_ORDER_ENTRIES: usize = 3;
14041412
const MAX_CAPTCHA_SOLVER_IDENTIFIER_BYTES: usize = 128;
1413+
/// One entry per `ResolutionTier` variant; duplicates are dropped by
1414+
/// `normalize_resolution_order`, so a longer list is malformed input.
1415+
const MAX_RESOLUTION_ORDER_ENTRIES: usize = 3;
1416+
/// The longest tier name is `premium`. Anything beyond this is malformed
1417+
/// input whose only effect would be an oversized IPC error string, since
1418+
/// the parse failure quotes what it rejected.
1419+
const MAX_RESOLUTION_TIER_IDENTIFIER_BYTES: usize = 16;
14051420

14061421
#[derive(Debug, Clone, Default, serde::Deserialize)]
14071422
#[serde(rename_all = "camelCase")]
@@ -1438,6 +1453,9 @@ pub struct ConfigPatchDto {
14381453
/// Accepted values: `"best_traffic"`, `"round_robin"`, `"manual"`.
14391454
/// Unknown values are rejected by `ConfigPatch::try_from(ConfigPatchDto)`.
14401455
pub account_selection_strategy: Option<String>,
1456+
/// Accepted values: `"premium"`, `"debrid"`, `"free"`. Unknown values
1457+
/// are rejected by `ConfigPatch::try_from(ConfigPatchDto)`.
1458+
pub resolution_order: Option<Vec<String>>,
14411459

14421460
// Network
14431461
pub proxy_type: Option<String>,
@@ -1488,6 +1506,27 @@ impl TryFrom<ConfigPatchDto> for ConfigPatch {
14881506
Some(raw) => Some(raw.parse().map_err(|e: DomainError| e.to_string())?),
14891507
None => None,
14901508
};
1509+
if let Some(order) = &d.resolution_order {
1510+
if order.len() > MAX_RESOLUTION_ORDER_ENTRIES {
1511+
return Err("Resolution order exceeds safety limits".to_string());
1512+
}
1513+
if order
1514+
.iter()
1515+
.any(|tier| tier.len() > MAX_RESOLUTION_TIER_IDENTIFIER_BYTES)
1516+
{
1517+
return Err("Resolution tier identifier exceeds safety limits".to_string());
1518+
}
1519+
}
1520+
let resolution_order = match &d.resolution_order {
1521+
Some(order) => Some(
1522+
order
1523+
.iter()
1524+
.map(|tier| tier.parse())
1525+
.collect::<Result<Vec<ResolutionTier>, DomainError>>()
1526+
.map_err(|e| e.to_string())?,
1527+
),
1528+
None => None,
1529+
};
14911530
Ok(Self {
14921531
download_dir: d.download_dir,
14931532
start_minimized: d.start_minimized,
@@ -1510,6 +1549,7 @@ impl TryFrom<ConfigPatchDto> for ConfigPatch {
15101549
captcha_solver_order: d.captcha_solver_order,
15111550
history_retention_days: d.history_retention_days,
15121551
account_selection_strategy,
1552+
resolution_order,
15131553
proxy_type: d.proxy_type,
15141554
proxy_url: d.proxy_url,
15151555
user_agent: d.user_agent,
@@ -4979,6 +5019,23 @@ mod tests {
49795019
);
49805020
}
49815021

5022+
#[test]
5023+
fn config_patch_dto_rejects_an_oversized_resolution_tier_identifier() {
5024+
use super::{ConfigPatch, ConfigPatchDto};
5025+
5026+
// The parse error quotes what it rejected, so an unbounded tier
5027+
// name would come straight back as an oversized IPC error.
5028+
let dto = ConfigPatchDto {
5029+
resolution_order: Some(vec!["x".repeat(4096)]),
5030+
..Default::default()
5031+
};
5032+
5033+
let result: Result<ConfigPatch, String> = dto.try_into();
5034+
let error = result.expect_err("oversized tier identifier must be rejected");
5035+
assert!(error.contains("tier identifier"), "{error}");
5036+
assert!(!error.contains("xxxx"), "{error}");
5037+
}
5038+
49825039
#[test]
49835040
fn captcha_browser_window_is_bound_to_its_challenge() {
49845041
let own_id = crate::domain::model::captcha::CaptchaId::new("captcha-1");

src-tauri/src/application/commands/hoster_download_source.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
use super::ResolveHosterSourceHandler;
44
use crate::application::services::download_source_policy::classify_download_module;
55
use crate::domain::error::DomainError;
6+
use crate::domain::model::config::ResolutionTier;
67
use crate::domain::model::download::Download;
8+
use crate::domain::model::plugin::PluginCategory;
79
use crate::domain::ports::driven::{
810
DownloadSourceResolver, ExtractedHosterLink, ResolutionCancellation, ResolvedDownloadSource,
911
};
@@ -55,7 +57,14 @@ impl ResolveHosterSourceHandler {
5557
cancellation: &ResolutionCancellation,
5658
) -> Result<ResolvedDownloadSource, DomainError> {
5759
if download.account_id().is_some() {
58-
return self.resolve_download(download, cancellation);
60+
let error = match self.resolve_download(download, cancellation) {
61+
Ok(source) => return Ok(source),
62+
Err(error) => error,
63+
};
64+
if self.debrid_falls_through(download, cancellation) {
65+
return self.free_tier_source(download, error);
66+
}
67+
return Err(error);
5968
}
6069
cancellation.ensure_active()?;
6170
let service_name = download.module_name().ok_or_else(|| {
@@ -67,4 +76,73 @@ impl ResolveHosterSourceHandler {
6776
cancellation.ensure_active()?;
6877
resolved_protected_source(link)
6978
}
79+
80+
/// A debrid is one rung of the cascade, not the only route to the file.
81+
/// The link check picked it while it was healthy; by the time the engine
82+
/// asks for a URL the quota can be spent, the hoster dropped out of
83+
/// coverage, or the service down. R-04 wants the next rung tried with an
84+
/// explicit reason instead of a dead download.
85+
///
86+
/// Only the free rung is retried. Premium is deliberately not: any
87+
/// premium account for the hoster was already offered this link at check
88+
/// time and lost to the debrid, so re-offering it replays a decision.
89+
/// A lookup that itself fails leaves the fall-through unproven, and then
90+
/// the debrid error stands.
91+
fn debrid_falls_through(
92+
&self,
93+
download: &Download,
94+
cancellation: &ResolutionCancellation,
95+
) -> bool {
96+
let Some(module) = download.module_name() else {
97+
return false;
98+
};
99+
cancellation.ensure_active().is_ok()
100+
&& self
101+
.config
102+
.get_config()
103+
.is_ok_and(|config| config.resolution_order.contains(&ResolutionTier::Free))
104+
&& self.plugins.list_loaded().is_ok_and(|infos| {
105+
infos
106+
.iter()
107+
.any(|info| info.name() == module && info.category() == PluginCategory::Debrid)
108+
})
109+
}
110+
111+
/// Anonymous extraction through the plugin that owns the URL, keeping the
112+
/// debrid's reason alongside the free one when neither rung delivers.
113+
fn free_tier_source(
114+
&self,
115+
download: &Download,
116+
debrid_error: DomainError,
117+
) -> Result<ResolvedDownloadSource, DomainError> {
118+
let url = download.url().as_str();
119+
// `resolve_url` keeps debrid candidates and merely orders them last,
120+
// so the runner-up for a URL no hoster claims is another debrid.
121+
// Calling it without credentials is not a free extraction, and
122+
// reporting its refusal as "free" would name a rung never tried.
123+
let hoster = match self.plugins.resolve_url(url) {
124+
Ok(Some(info))
125+
if info.category() == PluginCategory::Hoster
126+
&& Some(info.name()) != download.module_name() =>
127+
{
128+
info.name().to_string()
129+
}
130+
_ => return Err(debrid_error),
131+
};
132+
self.plugins
133+
.extract_hoster_link(&hoster, url, None)
134+
.and_then(resolved_protected_source)
135+
.map_err(|free_error| match free_error {
136+
// The engine answers a challenge; burying it in a text
137+
// summary would strand the download instead.
138+
captcha @ DomainError::CaptchaRequired { .. } => captcha,
139+
free_error => DomainError::ResolutionExhausted(format!(
140+
"debrid: {debrid_error}; free: {free_error}"
141+
)),
142+
})
143+
}
70144
}
145+
146+
#[cfg(test)]
147+
#[path = "hoster_download_source_tests.rs"]
148+
mod tests;

0 commit comments

Comments
 (0)