mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-03 23:46:45 +00:00
1324 lines
50 KiB
Rust
1324 lines
50 KiB
Rust
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BackendSelectionRequest {
|
|
#[serde(default)]
|
|
stream: Value,
|
|
#[serde(default)]
|
|
preferred_player: Option<String>,
|
|
#[serde(default)]
|
|
device_has_dolby_vision_decoder: bool,
|
|
#[serde(default)]
|
|
device_has_hdr_display: bool,
|
|
#[serde(default)]
|
|
force_software_audio: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct TorrentFallbackRequest {
|
|
#[serde(default)]
|
|
file_stats: Vec<Value>,
|
|
#[serde(default)]
|
|
rejected_index: Option<i32>,
|
|
#[serde(default)]
|
|
video_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BufferTargetsRequest {
|
|
#[serde(default)]
|
|
forward_buffer_seconds: Option<i64>,
|
|
#[serde(default)]
|
|
back_buffer_seconds: Option<i64>,
|
|
#[serde(default)]
|
|
cache_size_mb: Option<i64>,
|
|
#[serde(default)]
|
|
is_torrent: bool,
|
|
#[serde(default)]
|
|
mobile_data_usage: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RetryPolicyRequest {
|
|
error_code: String,
|
|
#[serde(default)]
|
|
retry_count: i32,
|
|
#[serde(default)]
|
|
is_torrent: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SourceSidebarRequest {
|
|
#[serde(default)]
|
|
streams: Vec<Value>,
|
|
#[serde(default)]
|
|
current_stream_index: i32,
|
|
#[serde(default)]
|
|
available_addons: Vec<String>,
|
|
#[serde(default)]
|
|
selected_addon: Option<String>,
|
|
}
|
|
|
|
pub(crate) fn player_backend_selection_json(request_json: &str) -> Option<String> {
|
|
let request = serde_json::from_str::<BackendSelectionRequest>(request_json).ok()?;
|
|
let preferred = request.preferred_player.as_deref().unwrap_or("internal");
|
|
let stream = &request.stream;
|
|
|
|
let url = stream
|
|
.get("playableUrl")
|
|
.or_else(|| stream.get("url"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
|
|
let is_external_player_url = url.starts_with("intent://")
|
|
|| stream
|
|
.get("externalPlayerUrl")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
|| preferred == "external";
|
|
|
|
if is_external_player_url {
|
|
return serde_json::to_string(&json!({
|
|
"backend": "external",
|
|
"reason": "external_player_preference"
|
|
}))
|
|
.ok();
|
|
}
|
|
|
|
// MPV is preferred for:
|
|
// - HDR / Dolby Vision streams when device doesn't have native HW decoder
|
|
// - Streams that specify mpv hints
|
|
// - User explicitly chose MPV
|
|
let has_mpv_hint = stream
|
|
.get("behaviorHints")
|
|
.and_then(|h| h.get("preferMpv"))
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
|
|
let is_dv_stream = stream.get("dv").and_then(Value::as_bool).unwrap_or(false)
|
|
|| stream.get("dolbyVision").and_then(Value::as_bool).unwrap_or(false);
|
|
let is_hdr_stream = stream.get("hdr").and_then(Value::as_bool).unwrap_or(false);
|
|
let needs_mpv_for_hdr = (is_dv_stream && !request.device_has_dolby_vision_decoder)
|
|
|| (is_hdr_stream && !request.device_has_hdr_display);
|
|
|
|
let use_mpv = preferred == "mpv"
|
|
|| has_mpv_hint
|
|
|| needs_mpv_for_hdr
|
|
|| (request.force_software_audio && preferred != "exoplayer");
|
|
|
|
let backend = if use_mpv { "mpv" } else { "exoplayer" };
|
|
let reason = if preferred == "mpv" || preferred == "exoplayer" {
|
|
"user_preference"
|
|
} else if has_mpv_hint {
|
|
"stream_hint"
|
|
} else if needs_mpv_for_hdr {
|
|
"hdr_no_hw_decoder"
|
|
} else if request.force_software_audio {
|
|
"software_audio"
|
|
} else {
|
|
"default"
|
|
};
|
|
|
|
serde_json::to_string(&json!({
|
|
"backend": backend,
|
|
"reason": reason
|
|
}))
|
|
.ok()
|
|
}
|
|
|
|
/// Determine fallback file indexes to try when the primary torrent file fails validation.
|
|
/// Excludes the rejected index and prioritizes by file size.
|
|
pub(crate) fn torrent_fallback_file_policy_json(request_json: &str) -> Option<String> {
|
|
let request = serde_json::from_str::<TorrentFallbackRequest>(request_json).ok()?;
|
|
let rejected = request.rejected_index;
|
|
let video_id = request.video_id.as_deref().unwrap_or("");
|
|
|
|
// Collect video-likely files (by extension)
|
|
let video_exts = [
|
|
".mkv", ".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v",
|
|
];
|
|
let mut candidates: Vec<(i32, i64)> = request
|
|
.file_stats
|
|
.iter()
|
|
.filter_map(|stat| {
|
|
let id = stat.get("id").and_then(Value::as_i64)? as i32;
|
|
if rejected == Some(id) {
|
|
return None;
|
|
}
|
|
let path = stat.get("path").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
|
let is_video = video_exts.iter().any(|ext| path.ends_with(ext));
|
|
if !is_video {
|
|
return None;
|
|
}
|
|
let length = stat.get("length").and_then(Value::as_i64).unwrap_or(0);
|
|
// Skip tiny files (less than 1MB) unless it's the only candidate
|
|
if length < 1_000_000 && request.file_stats.len() > 1 {
|
|
return None;
|
|
}
|
|
Some((id, length))
|
|
})
|
|
.collect();
|
|
|
|
// Sort by size descending (largest first as most likely the right video file)
|
|
candidates.sort_by(|a, b| b.1.cmp(&a.1));
|
|
|
|
// If we have a video_id hint, try to match by episode pattern
|
|
let fallback_ids: Vec<i32> = if !video_id.is_empty() {
|
|
// Episode-matched first, then size-sorted remainder
|
|
let mut matched: Vec<(i32, i64)> = Vec::new();
|
|
let mut unmatched: Vec<(i32, i64)> = Vec::new();
|
|
for (id, length) in &candidates {
|
|
let path = request
|
|
.file_stats
|
|
.iter()
|
|
.find(|s| s.get("id").and_then(Value::as_i64) == Some(*id as i64))
|
|
.and_then(|s| s.get("path").and_then(Value::as_str))
|
|
.unwrap_or("");
|
|
if episode_path_matches_id(path, video_id) {
|
|
matched.push((*id, *length));
|
|
} else {
|
|
unmatched.push((*id, *length));
|
|
}
|
|
}
|
|
matched
|
|
.iter()
|
|
.chain(unmatched.iter())
|
|
.map(|(id, _)| *id)
|
|
.collect()
|
|
} else {
|
|
candidates.iter().map(|(id, _)| *id).collect()
|
|
};
|
|
|
|
serde_json::to_string(&json!({
|
|
"fallbackFileIndexes": fallback_ids,
|
|
"rejectedIndex": rejected
|
|
}))
|
|
.ok()
|
|
}
|
|
|
|
/// Return safe buffer and cache targets for ExoPlayer given preferences and stream type.
|
|
pub(crate) fn player_buffer_targets_json(request_json: &str) -> Option<String> {
|
|
let request = serde_json::from_str::<BufferTargetsRequest>(request_json).ok()?;
|
|
let mobile_data_usage = request.mobile_data_usage.as_deref().unwrap_or("medium");
|
|
|
|
// On mobile data, reduce buffers
|
|
let data_factor: f64 = match mobile_data_usage {
|
|
"low" => 0.5,
|
|
"high" => 1.5,
|
|
_ => 1.0,
|
|
};
|
|
|
|
let base_forward_ms = request
|
|
.forward_buffer_seconds
|
|
.unwrap_or(120)
|
|
.clamp(10, 600) as f64
|
|
* 1000.0
|
|
* data_factor;
|
|
let base_back_ms = request
|
|
.back_buffer_seconds
|
|
.unwrap_or(30)
|
|
.clamp(5, 120) as f64
|
|
* 1000.0;
|
|
|
|
// Torrent streams need smaller buffers to avoid filling the local proxy
|
|
let (forward_ms, back_ms) = if request.is_torrent {
|
|
(base_forward_ms.min(30_000.0), base_back_ms.min(15_000.0))
|
|
} else {
|
|
(base_forward_ms, base_back_ms)
|
|
};
|
|
|
|
let cache_bytes = request
|
|
.cache_size_mb
|
|
.map(|mb| mb.clamp(10, 2000) * 1_000_000)
|
|
.unwrap_or(100 * 1_000_000i64);
|
|
|
|
serde_json::to_string(&json!({
|
|
"forwardBufferMs": forward_ms as i64,
|
|
"backBufferMs": back_ms as i64,
|
|
"cacheSizeBytes": cache_bytes
|
|
}))
|
|
.ok()
|
|
}
|
|
|
|
/// Return the retry/fallback policy given an error code and retry history.
|
|
pub(crate) fn player_retry_policy_json(request_json: &str) -> Option<String> {
|
|
let request = serde_json::from_str::<RetryPolicyRequest>(request_json).ok()?;
|
|
let error_code = request.error_code.as_str();
|
|
let retry_count = request.retry_count;
|
|
|
|
// Non-retryable errors
|
|
let is_fatal = matches!(
|
|
error_code,
|
|
"no_source"
|
|
| "drm_not_supported"
|
|
| "drm_session_error"
|
|
| "format_unsupported"
|
|
| "missing_profile"
|
|
);
|
|
|
|
if is_fatal || retry_count >= 3 {
|
|
return serde_json::to_string(&json!({
|
|
"shouldRetry": false,
|
|
"fallbackAction": if is_fatal { "show_error" } else { "show_error_with_retry_button" },
|
|
"delayMs": 0
|
|
}))
|
|
.ok();
|
|
}
|
|
|
|
// Torrent errors get a longer delay
|
|
let (should_retry, delay_ms, fallback_action) = if request.is_torrent {
|
|
match error_code {
|
|
"timeout" | "connection_error" | "buffer_timeout" => {
|
|
(true, 2000u64 * (retry_count as u64 + 1), "retry_stream")
|
|
}
|
|
"torrent_no_file" | "torrent_file_validation_failed" => {
|
|
(true, 1000, "try_fallback_file")
|
|
}
|
|
_ => (false, 0, "show_error"),
|
|
}
|
|
} else {
|
|
match error_code {
|
|
"timeout" | "connection_error" | "io_error" => {
|
|
(true, 1000u64 * (retry_count as u64 + 1), "retry_stream")
|
|
}
|
|
"renderer_error" | "decode_error" => (true, 500, "retry_with_sw_decoder"),
|
|
_ => (false, 0, "show_error"),
|
|
}
|
|
};
|
|
|
|
serde_json::to_string(&json!({
|
|
"shouldRetry": should_retry,
|
|
"fallbackAction": fallback_action,
|
|
"delayMs": delay_ms,
|
|
"retryCount": retry_count
|
|
}))
|
|
.ok()
|
|
}
|
|
|
|
/// Build the source sidebar option state: which streams to show and which is selected.
|
|
pub(crate) fn player_source_sidebar_plan_json(request_json: &str) -> Option<String> {
|
|
let request = serde_json::from_str::<SourceSidebarRequest>(request_json).ok()?;
|
|
let current_index = request.current_stream_index.clamp(0, i32::MAX);
|
|
let streams_by_addon: std::collections::BTreeMap<String, Vec<(usize, &Value)>> = request
|
|
.streams
|
|
.iter()
|
|
.enumerate()
|
|
.fold(std::collections::BTreeMap::new(), |mut acc, (i, stream)| {
|
|
let addon_name = stream
|
|
.get("addonName")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("Unknown")
|
|
.to_string();
|
|
acc.entry(addon_name).or_default().push((i, stream));
|
|
acc
|
|
});
|
|
|
|
let groups: Vec<Value> = streams_by_addon
|
|
.into_iter()
|
|
.map(|(addon_name, streams)| {
|
|
let entries: Vec<Value> = streams
|
|
.iter()
|
|
.map(|(idx, stream)| {
|
|
json!({
|
|
"index": idx,
|
|
"isSelected": *idx == current_index as usize,
|
|
"title": stream.get("title").cloned().unwrap_or_else(|| json!("")),
|
|
"name": stream.get("name").cloned().unwrap_or_else(|| json!("")),
|
|
"quality": stream.get("quality").cloned().unwrap_or(Value::Null)
|
|
})
|
|
})
|
|
.collect();
|
|
json!({
|
|
"addonName": addon_name,
|
|
"streams": entries,
|
|
"isSelected": entries.iter().any(|e| e["isSelected"].as_bool().unwrap_or(false))
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
serde_json::to_string(&json!({
|
|
"groups": groups,
|
|
"currentStreamIndex": current_index,
|
|
"availableAddons": request.available_addons,
|
|
"selectedAddon": request.selected_addon
|
|
}))
|
|
.ok()
|
|
}
|
|
|
|
// ── DV proxy plan ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct DvProxyPlanRequest {
|
|
#[serde(default)]
|
|
stream: Value,
|
|
#[serde(default)]
|
|
url: String,
|
|
/// "off" | "hdr10" | "dv8" | "auto"
|
|
#[serde(default = "default_auto")]
|
|
fallback_mode: String,
|
|
#[serde(default)]
|
|
device_has_dv_decoder: bool,
|
|
#[serde(default)]
|
|
device_has_dv_display: bool,
|
|
}
|
|
|
|
fn default_auto() -> String {
|
|
"auto".to_string()
|
|
}
|
|
|
|
/// Per-stream Dolby Vision profile classification.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum DvProfile {
|
|
/// Profile 4 — AVC single-layer, no HDR base layer.
|
|
P4,
|
|
/// Profile 5 — HEVC single-layer, no HDR base layer.
|
|
P5,
|
|
/// Profile 7 — dual-layer BL+EL with HDR10-compatible base.
|
|
P7,
|
|
/// Profile 8, compat_id=1 — single-layer HEVC, HDR10 base. Safest fallback.
|
|
P8Hdr10,
|
|
/// Profile 8, compat_id=4 — single-layer HEVC, HLG base.
|
|
P8Hlg,
|
|
/// Profile 8 with unrecognised compat_id — HDR10 assumed but uncertain.
|
|
P8Unknown,
|
|
/// Profile 10, compat_id=1 — HDR10-compatible base.
|
|
P10Hdr10,
|
|
/// Profile 10, compat_id 0/2/3 — no HDR10 base.
|
|
P10Other,
|
|
/// Profile could not be determined from stream metadata.
|
|
Unknown,
|
|
}
|
|
|
|
impl DvProfile {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
DvProfile::P4 => "P4",
|
|
DvProfile::P5 => "P5",
|
|
DvProfile::P7 => "P7",
|
|
DvProfile::P8Hdr10 => "P8.1",
|
|
DvProfile::P8Hlg => "P8.4",
|
|
DvProfile::P8Unknown => "P8",
|
|
DvProfile::P10Hdr10 => "P10_compat1",
|
|
DvProfile::P10Other => "P10_other",
|
|
DvProfile::Unknown => "unknown",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the recommended DV proxy action for a single stream+URL combination.
|
|
///
|
|
/// Response fields:
|
|
/// action — "none" | "dvcc_strip" | "rpu_convert"
|
|
/// rpuMode — libdovi convert mode (2 = Profile 8)
|
|
/// reason — machine-readable decision code
|
|
/// profile — detected DV profile ("P7", "P8.1", …, "unknown")
|
|
/// compatibility — expected output format ("HDR10", "HLG", "DV8", "DV", "none")
|
|
/// safety — "high" | "medium" | "low" | "none"
|
|
/// limitations — list of known caveats for this action
|
|
pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option<String> {
|
|
let req = serde_json::from_str::<DvProxyPlanRequest>(request_json).ok()?;
|
|
|
|
if req.fallback_mode == "off" {
|
|
return plan_rich("none", "user_disabled", "unknown", "none", "high", &[]);
|
|
}
|
|
|
|
// HLS / DASH manifests are rewritten by the OkHttp interceptor — no proxy needed.
|
|
let url_lower = req.url.to_lowercase();
|
|
if url_lower.ends_with(".m3u8")
|
|
|| url_lower.contains(".m3u8?")
|
|
|| url_lower.ends_with(".mpd")
|
|
|| url_lower.contains(".mpd?")
|
|
{
|
|
return plan_rich("none", "manifest_handled", "unknown", "none", "high", &[]);
|
|
}
|
|
|
|
if !is_dolby_vision_stream(&req.stream, &req.url) {
|
|
return plan_rich("none", "not_dv", "unknown", "none", "high", &[]);
|
|
}
|
|
|
|
// Native passthrough: decoder + display always wins regardless of mode.
|
|
// Exception for convert_dv81: decoder without display should still convert P7 → P8.1
|
|
// so the decoder applies dynamic RPU tone mapping rather than static HDR10.
|
|
let native_passthrough = req.device_has_dv_decoder
|
|
&& (req.device_has_dv_display || req.fallback_mode != "convert_dv81");
|
|
if native_passthrough {
|
|
return plan_rich("none", "hw_dv_decoder", "unknown", "DV", "high", &[]);
|
|
}
|
|
|
|
let profile = detect_dv_profile(&req.stream);
|
|
let container = detect_container(&req.url);
|
|
|
|
// Hard safety gates: profiles with no HDR base layer cannot be safely
|
|
// rewritten — stripping DVCC would expose a DV-only bitstream to an
|
|
// HDR10 decoder, producing corrupted colour.
|
|
match profile {
|
|
DvProfile::P4 | DvProfile::P5 => {
|
|
return plan_rich(
|
|
"none",
|
|
"no_hdr_base_layer",
|
|
profile.label(),
|
|
"none",
|
|
"none",
|
|
&["p4_p5_no_hdr_fallback_possible"],
|
|
);
|
|
}
|
|
DvProfile::P10Other => {
|
|
return plan_rich(
|
|
"none",
|
|
"p10_compat_id_no_hdr_base",
|
|
profile.label(),
|
|
"none",
|
|
"none",
|
|
&["only_p10_compat_id_1_has_hdr10_base"],
|
|
);
|
|
}
|
|
// Unknown profile: do nothing rather than guess and corrupt playback.
|
|
DvProfile::Unknown => {
|
|
return plan_rich(
|
|
"none",
|
|
"unknown_profile_no_safe_fallback",
|
|
"unknown",
|
|
"none",
|
|
"none",
|
|
&["set_dvProfile_field_or_codec_string_for_safe_rewrite"],
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
let mode = req.fallback_mode.as_str();
|
|
|
|
let (action, compat, safety, reason, limitations): (&str, &str, &str, &str, Vec<&str>) =
|
|
match profile {
|
|
DvProfile::P7 => match (mode, &container) {
|
|
// convert_dv81 + DV decoder (no display): RPU conversion for Annex-B + fMP4.
|
|
// Without a DV decoder, conversion would produce DV8.1 that nothing can decode;
|
|
// fall through to dvcc_strip (handled by the catch-all arm below).
|
|
("convert_dv81", _) if req.device_has_dv_decoder => (
|
|
"rpu_convert",
|
|
"DV8",
|
|
"medium",
|
|
"p7_rpu_convert_to_dv81",
|
|
vec![],
|
|
),
|
|
("dv8", DvContainer::RawHevc) => (
|
|
"rpu_convert",
|
|
"DV8",
|
|
"medium",
|
|
"p7_rpu_convert_to_dv8_annexb",
|
|
vec!["annexb_only"],
|
|
),
|
|
// Auto mode + DV-capable display: keep DV via RPU conversion.
|
|
("auto", DvContainer::RawHevc) if req.device_has_dv_display => (
|
|
"rpu_convert",
|
|
"DV8",
|
|
"medium",
|
|
"p7_rpu_convert_auto_dv_display_annexb",
|
|
vec!["annexb_only"],
|
|
),
|
|
// dv8 mode requested but container is not Annex-B.
|
|
("dv8", _) => (
|
|
"dvcc_strip",
|
|
"HDR10",
|
|
"medium",
|
|
"rpu_convert_rejected_not_annexb",
|
|
vec!["rpu_convert_requires_annexb_hevc", "container_is_not_raw_hevc_fallback_to_dvcc_strip", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
|
|
),
|
|
_ => (
|
|
"dvcc_strip",
|
|
"HDR10",
|
|
"medium",
|
|
"p7_dvcc_strip_hdr10_base",
|
|
vec!["does_not_convert_bitstream", "rpu_nals_remain_in_stream_ignored", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
|
|
),
|
|
},
|
|
DvProfile::P8Hdr10 => (
|
|
"dvcc_strip",
|
|
"HDR10",
|
|
"low",
|
|
"p8_1_hdr10_compat_base",
|
|
vec!["single_layer_hdr10_base_fully_compatible", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
|
|
),
|
|
DvProfile::P8Hlg => (
|
|
"dvcc_strip",
|
|
"HLG",
|
|
"medium",
|
|
"p8_4_hlg_compat_base",
|
|
vec!["hlg_base_not_hdr10_color_rendering_may_differ", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
|
|
),
|
|
DvProfile::P8Unknown => (
|
|
"dvcc_strip",
|
|
"HDR10_assumed",
|
|
"medium",
|
|
"p8_compat_id_unknown_hdr10_assumed",
|
|
vec!["compat_id_unknown_hdr10_base_assumed", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
|
|
),
|
|
DvProfile::P10Hdr10 => (
|
|
"dvcc_strip",
|
|
"HDR10",
|
|
"medium",
|
|
"p10_compat_id_1_hdr10_base",
|
|
vec!["does_not_convert_bitstream", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
|
|
),
|
|
// P4, P5, P10Other, Unknown already returned above.
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
plan_rich(action, reason, profile.label(), compat, safety, &limitations)
|
|
}
|
|
|
|
fn plan_rich(
|
|
action: &str,
|
|
reason: &str,
|
|
profile: &str,
|
|
compatibility: &str,
|
|
safety: &str,
|
|
limitations: &[&str],
|
|
) -> Option<String> {
|
|
serde_json::to_string(&json!({
|
|
"action": action,
|
|
"rpuMode": 2u8,
|
|
"reason": reason,
|
|
"profile": profile,
|
|
"compatibility": compatibility,
|
|
"safety": safety,
|
|
"limitations": limitations,
|
|
}))
|
|
.ok()
|
|
}
|
|
|
|
/// Derive the DV profile from stream metadata, codec strings, and text hints.
|
|
fn detect_dv_profile(stream: &Value) -> DvProfile {
|
|
// 1. Explicit integer fields set by the addon.
|
|
let profile_num = stream
|
|
.get("dvProfile")
|
|
.or_else(|| stream.get("dv_profile"))
|
|
.and_then(Value::as_i64);
|
|
let compat_id = stream
|
|
.get("dvCompatId")
|
|
.or_else(|| stream.get("dvCompatibility"))
|
|
.and_then(Value::as_i64);
|
|
if let Some(p) = profile_num {
|
|
return profile_from_nums(p, compat_id);
|
|
}
|
|
|
|
// 2. ISO-BMFF / HLS codec string: "dvhe.07.06", "dvh1.08.01", …
|
|
let codecs = stream.get("codecs").and_then(Value::as_str).unwrap_or("");
|
|
if let Some(p) = parse_dv_codec_string(codecs) {
|
|
return p;
|
|
}
|
|
|
|
// 3. Codec token embedded in freetext fields (e.g., "dvhe.07.06 BDRemux").
|
|
let name = stream.get("name").and_then(Value::as_str).unwrap_or("");
|
|
let desc = stream.get("description").and_then(Value::as_str).unwrap_or("");
|
|
let filename = stream
|
|
.get("effectiveFilename")
|
|
.or_else(|| stream.get("filename"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let text = format!("{} {} {}", name, desc, filename);
|
|
if let Some(p) = parse_dv_codec_string(&text) {
|
|
return p;
|
|
}
|
|
|
|
// 4. Short profile tokens: "P8.1", "P7", "P8", …
|
|
parse_dv_profile_text(&text).unwrap_or(DvProfile::Unknown)
|
|
}
|
|
|
|
fn profile_from_nums(profile: i64, compat_id: Option<i64>) -> DvProfile {
|
|
match profile {
|
|
4 => DvProfile::P4,
|
|
5 => DvProfile::P5,
|
|
7 => DvProfile::P7,
|
|
8 => match compat_id {
|
|
Some(1) => DvProfile::P8Hdr10,
|
|
Some(4) => DvProfile::P8Hlg,
|
|
_ => DvProfile::P8Unknown,
|
|
},
|
|
10 => match compat_id {
|
|
Some(1) => DvProfile::P10Hdr10,
|
|
_ => DvProfile::P10Other,
|
|
},
|
|
_ => DvProfile::Unknown,
|
|
}
|
|
}
|
|
|
|
/// Parse a DV fourcc codec string such as "dvhe.07.06" → P7.
|
|
fn parse_dv_codec_string(text: &str) -> Option<DvProfile> {
|
|
let lower = text.to_lowercase();
|
|
for prefix in &["dvhe.", "dvh1.", "dva1.", "dvav."] {
|
|
if let Some(pos) = lower.find(prefix) {
|
|
let after = &text[pos + prefix.len()..];
|
|
let mut parts = after.splitn(3, '.');
|
|
// Take only the leading digits from each field (e.g. "08" from "08.01 Remux").
|
|
let profile: i64 = leading_digits(parts.next()?)?.parse().ok()?;
|
|
let compat: Option<i64> = parts.next()
|
|
.and_then(leading_digits)
|
|
.and_then(|s| s.parse().ok());
|
|
return Some(profile_from_nums(profile, compat));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn leading_digits(s: &str) -> Option<&str> {
|
|
let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
|
|
if end == 0 { None } else { Some(&s[..end]) }
|
|
}
|
|
|
|
/// Recognise short profile tokens ("P8.1", "P7", "P8") in freetext.
|
|
fn parse_dv_profile_text(text: &str) -> Option<DvProfile> {
|
|
// Ordered so longer patterns match before their shorter prefixes.
|
|
let patterns: &[(&str, DvProfile)] = &[
|
|
("P8.1", DvProfile::P8Hdr10),
|
|
("P8.4", DvProfile::P8Hlg),
|
|
("P7", DvProfile::P7),
|
|
("P8", DvProfile::P8Unknown),
|
|
("P10", DvProfile::P10Other),
|
|
("P5", DvProfile::P5),
|
|
("P4", DvProfile::P4),
|
|
];
|
|
for (pat, profile) in patterns {
|
|
if contains_word(text, pat) {
|
|
return Some(*profile);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// True when `word` appears in `text` surrounded by non-alphanumeric (or absent) bytes.
|
|
fn contains_word(text: &str, word: &str) -> bool {
|
|
let tb = text.as_bytes();
|
|
let wb = word.as_bytes();
|
|
let wlen = wb.len();
|
|
if tb.len() < wlen {
|
|
return false;
|
|
}
|
|
for i in 0..=(tb.len() - wlen) {
|
|
if &tb[i..i + wlen] == wb {
|
|
let before_ok = i == 0 || !tb[i - 1].is_ascii_alphanumeric();
|
|
let after_ok = i + wlen >= tb.len() || !tb[i + wlen].is_ascii_alphanumeric();
|
|
if before_ok && after_ok {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Returns true when the stream or URL is identifiable as Dolby Vision content.
|
|
fn is_dolby_vision_stream(stream: &Value, url: &str) -> bool {
|
|
if stream.get("dv").and_then(Value::as_bool).unwrap_or(false)
|
|
|| stream.get("dolbyVision").and_then(Value::as_bool).unwrap_or(false)
|
|
|| stream.get("dvProfile").and_then(Value::as_i64).is_some()
|
|
{
|
|
return true;
|
|
}
|
|
|
|
let name = stream.get("name").and_then(Value::as_str).unwrap_or("");
|
|
let desc = stream.get("description").and_then(Value::as_str).unwrap_or("");
|
|
let filename = stream
|
|
.get("effectiveFilename")
|
|
.or_else(|| stream.get("filename"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
|
|
let lower = format!("{} {} {} {}", name, desc, filename, url).to_lowercase();
|
|
|
|
if lower.contains("dvhe")
|
|
|| lower.contains("dvh1")
|
|
|| lower.contains("dva1")
|
|
|| lower.contains("dvav")
|
|
|| lower.contains("dolby vision")
|
|
|| lower.contains("dolby-vision")
|
|
|| lower.contains("dovi")
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// "DV" as a standalone token (case-sensitive — avoids "DVD", "HDVD", etc.).
|
|
is_standalone_dv_token(&format!("{} {} {} {}", name, desc, filename, url))
|
|
}
|
|
|
|
fn is_standalone_dv_token(text: &str) -> bool {
|
|
let bytes = text.as_bytes();
|
|
let len = bytes.len();
|
|
let mut i = 0;
|
|
while i + 1 < len {
|
|
if bytes[i] == b'D' && bytes[i + 1] == b'V' {
|
|
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphabetic();
|
|
let after_ok = i + 2 >= len || !bytes[i + 2].is_ascii_alphanumeric();
|
|
if before_ok && after_ok {
|
|
return true;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
false
|
|
}
|
|
|
|
enum DvContainer {
|
|
Mkv,
|
|
Mp4,
|
|
RawHevc,
|
|
Unknown,
|
|
}
|
|
|
|
fn detect_container(url: &str) -> DvContainer {
|
|
let path = url.split('?').next().unwrap_or(url).to_lowercase();
|
|
let ext = path.rsplit('.').next().unwrap_or("");
|
|
match ext {
|
|
"mkv" | "mk3d" | "mka" | "mks" | "webm" => DvContainer::Mkv,
|
|
"mp4" | "m4v" | "m4a" | "mov" => DvContainer::Mp4,
|
|
"hevc" | "h265" | "265" => DvContainer::RawHevc,
|
|
_ => DvContainer::Unknown,
|
|
}
|
|
}
|
|
|
|
fn episode_path_matches_id(path: &str, video_id: &str) -> bool {
|
|
let parts: Vec<&str> = video_id.split(':').collect();
|
|
if parts.len() < 3 {
|
|
return false;
|
|
}
|
|
let season = parts[1].parse::<i32>().unwrap_or(0);
|
|
let episode = parts[2].parse::<i32>().unwrap_or(0);
|
|
if season == 0 || episode == 0 {
|
|
return false;
|
|
}
|
|
let path_lower = path.to_lowercase();
|
|
let pattern_s_e = format!("s{:02}e{:02}", season, episode);
|
|
let pattern_sx_ex = format!("{}x{:02}", season, episode);
|
|
let pattern_ep = format!("e{:02}", episode);
|
|
path_lower.contains(&pattern_s_e)
|
|
|| path_lower.contains(&pattern_sx_ex)
|
|
|| path_lower.contains(&pattern_ep)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::Value;
|
|
|
|
#[test]
|
|
fn backend_selection_defaults_to_exoplayer() {
|
|
let result: Value = serde_json::from_str(
|
|
&player_backend_selection_json(
|
|
r#"{"stream":{"url":"http://example.com/video.mp4"}}"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(result["backend"], "exoplayer");
|
|
}
|
|
|
|
#[test]
|
|
fn backend_selection_respects_mpv_user_preference() {
|
|
let result: Value = serde_json::from_str(
|
|
&player_backend_selection_json(
|
|
r#"{"stream":{"url":"http://example.com/video.mp4"},"preferredPlayer":"mpv"}"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(result["backend"], "mpv");
|
|
assert_eq!(result["reason"], "user_preference");
|
|
}
|
|
|
|
#[test]
|
|
fn torrent_fallback_excludes_rejected_index_and_sorts_by_size() {
|
|
let result: Value = serde_json::from_str(
|
|
&torrent_fallback_file_policy_json(
|
|
r#"{"rejectedIndex":1,"fileStats":[{"id":1,"path":"Big.mkv","length":1000000000},{"id":2,"path":"Small.mkv","length":500000000},{"id":3,"path":"Extras.mkv","length":200000000}]}"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let fallback: Vec<i64> = result["fallbackFileIndexes"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_i64().unwrap())
|
|
.collect();
|
|
assert!(!fallback.contains(&1), "rejected index must be excluded");
|
|
assert_eq!(fallback[0], 2, "largest remaining file should be first");
|
|
}
|
|
|
|
#[test]
|
|
fn buffer_targets_reduces_forward_buffer_for_torrent() {
|
|
let torrent_result: Value = serde_json::from_str(
|
|
&player_buffer_targets_json(
|
|
r#"{"forwardBufferSeconds":120,"backBufferSeconds":30,"isTorrent":true}"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let direct_result: Value = serde_json::from_str(
|
|
&player_buffer_targets_json(
|
|
r#"{"forwardBufferSeconds":120,"backBufferSeconds":30,"isTorrent":false}"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert!(torrent_result["forwardBufferMs"].as_i64().unwrap() < direct_result["forwardBufferMs"].as_i64().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn retry_policy_is_not_retryable_for_no_source() {
|
|
let result: Value = serde_json::from_str(
|
|
&player_retry_policy_json(r#"{"errorCode":"no_source","retryCount":0}"#).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(result["shouldRetry"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn retry_policy_retries_connection_errors_with_backoff() {
|
|
let result: Value = serde_json::from_str(
|
|
&player_retry_policy_json(
|
|
r#"{"errorCode":"timeout","retryCount":1,"isTorrent":false}"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(result["shouldRetry"], true);
|
|
assert!(result["delayMs"].as_i64().unwrap() > 0);
|
|
}
|
|
|
|
// ── dv_proxy_plan_json ─────────────────────────────────────────────────────
|
|
|
|
fn plan(json: &str) -> Value {
|
|
serde_json::from_str(&dv_proxy_plan_json(json).unwrap()).unwrap()
|
|
}
|
|
|
|
// ── Routing / mode gates ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn dv_proxy_off_mode_returns_none() {
|
|
let p = plan(r#"{"stream":{"name":"4K DV HDR","dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"off"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "user_disabled");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_hls_url_defers_to_manifest_rewrite() {
|
|
let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/index.m3u8","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "manifest_handled");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_dash_url_defers_to_manifest_rewrite() {
|
|
let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/stream.mpd","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "manifest_handled");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_non_dv_stream_returns_none() {
|
|
let p = plan(r#"{"stream":{"name":"1080p HDR AVC"},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "not_dv");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_hw_dv_decoder_skips_proxy() {
|
|
let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":true}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "hw_dv_decoder");
|
|
}
|
|
|
|
// ── Safety gates: profiles with no HDR base layer ─────────────────────────
|
|
|
|
#[test]
|
|
fn dv_proxy_p5_no_dv_decoder_returns_none() {
|
|
let p = plan(r#"{"stream":{"dvProfile":5},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "no_hdr_base_layer");
|
|
assert_eq!(p["profile"], "P5");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_p4_no_dv_decoder_returns_none() {
|
|
let p = plan(r#"{"stream":{"dvProfile":4},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "no_hdr_base_layer");
|
|
assert_eq!(p["profile"], "P4");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_p10_compat_0_returns_none() {
|
|
let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":0},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "p10_compat_id_no_hdr_base");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_p10_compat_2_returns_none() {
|
|
let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":2},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "none");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_unknown_profile_returns_none() {
|
|
// DV detected but no profile info → safe default is none.
|
|
let p = plan(r#"{"stream":{"name":"Dolby Vision"},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
|
}
|
|
|
|
// ── Profile-specific actions and safety levels ─────────────────────────────
|
|
|
|
#[test]
|
|
fn dv_proxy_p7_mkv_auto_gives_dvcc_strip_medium_safety() {
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false,"deviceHasDvDisplay":false}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P7");
|
|
assert_eq!(p["compatibility"], "HDR10");
|
|
assert_eq!(p["safety"], "medium");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_p8_1_gives_dvcc_strip_low_safety() {
|
|
let p = plan(r#"{"stream":{"dvProfile":8,"dvCompatId":1},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P8.1");
|
|
assert_eq!(p["compatibility"], "HDR10");
|
|
assert_eq!(p["safety"], "low");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_p8_4_fallback_is_hlg_not_hdr10() {
|
|
let p = plan(r#"{"stream":{"dvProfile":8,"dvCompatId":4},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P8.4");
|
|
assert_eq!(p["compatibility"], "HLG");
|
|
assert_ne!(p["compatibility"], "HDR10");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_p8_unknown_compat_strips_with_assumed_hdr10() {
|
|
// "DV P8" in name → P8Unknown → strip, medium safety, HDR10_assumed
|
|
let p = plan(r#"{"stream":{"name":"DV P8"},"url":"https://debrid.example/file.mkv","fallbackMode":"hdr10","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P8");
|
|
assert_eq!(p["compatibility"], "HDR10_assumed");
|
|
assert_eq!(p["safety"], "medium");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_p10_compat_1_gives_dvcc_strip() {
|
|
let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":1},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P10_compat1");
|
|
assert_eq!(p["compatibility"], "HDR10");
|
|
}
|
|
|
|
// ── rpu_convert: only for raw Annex-B HEVC ────────────────────────────────
|
|
|
|
#[test]
|
|
fn dv_proxy_p7_raw_hevc_dv8_mode_gives_rpu_convert() {
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/stream.hevc","fallbackMode":"dv8","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "rpu_convert");
|
|
assert_eq!(p["rpuMode"], 2);
|
|
assert_eq!(p["profile"], "P7");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_p7_raw_hevc_auto_dv_display_gives_rpu_convert() {
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/stream.hevc","fallbackMode":"auto","deviceHasDvDecoder":false,"deviceHasDvDisplay":true}"#);
|
|
assert_eq!(p["action"], "rpu_convert");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_rpu_convert_rejected_for_mkv_falls_back_to_dvcc_strip() {
|
|
// dv8 mode + MKV without a DV decoder → falls back to dvcc_strip because
|
|
// rpu_convert needs a DV decoder in the convert_dv81 path, and dv8 mode
|
|
// is annexb-only (rejects non-raw-HEVC containers).
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"dv8","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_proxy_rpu_convert_rejected_for_mp4_falls_back_to_dvcc_strip() {
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"dv8","deviceHasDvDecoder":false}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb");
|
|
}
|
|
|
|
// ── DV text detection ──────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn dv_detection_dolby_vision_p8_text_gives_action() {
|
|
// "P8" token → P8Unknown → dvcc_strip
|
|
let p = plan(r#"{"stream":{"name":"Dolby Vision P8"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_ne!(p["action"], "none");
|
|
assert_eq!(p["profile"], "P8");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_dovi_without_profile_gives_none() {
|
|
// DV detected ("dovi") but no profile info → unknown → none.
|
|
let p = plan(r#"{"stream":{"name":"4K DoVi 5.1"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_standalone_dv_without_profile_gives_none() {
|
|
// "[DV]" detected but no profile info → none.
|
|
let p = plan(r#"{"stream":{"name":"[4K] [DV] [HDR10+]"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_dvhe_fourcc_in_name_gives_profile_p7() {
|
|
let p = plan(r#"{"stream":{"name":"dvhe.07.06 BDRemux"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_ne!(p["action"], "none");
|
|
assert_eq!(p["profile"], "P7");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_dvhe_08_01_in_name_gives_p8_1() {
|
|
let p = plan(r#"{"stream":{"name":"dvhe.08.01 Remux"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P8.1");
|
|
assert_eq!(p["safety"], "low");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_no_false_positive_from_dvd() {
|
|
let p = plan(r#"{"stream":{"name":"DVD Rip 1080p"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "not_dv");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_no_false_positive_from_hdvd() {
|
|
let p = plan(r#"{"stream":{"name":"HDVD Edition"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_explicit_boolean_flag_with_profile() {
|
|
let p = plan(r#"{"stream":{"dv":true,"dvProfile":8,"dvCompatId":1,"name":"4K HDR"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_ne!(p["action"], "none");
|
|
assert_eq!(p["profile"], "P8.1");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_filename_without_profile_gives_none() {
|
|
// DV keyword in filename but no profile → safe default is none.
|
|
let p = plan(r#"{"stream":{"name":"4K HDR","effectiveFilename":"Movie.2023.UHD.DV.HEVC.mkv"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
|
}
|
|
|
|
#[test]
|
|
fn dv_detection_dvhe_codec_in_filename_gives_profile() {
|
|
let p = plan(r#"{"stream":{"effectiveFilename":"Movie.dvhe.07.06.mkv"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
|
|
assert_ne!(p["action"], "none");
|
|
assert_eq!(p["profile"], "P7");
|
|
}
|
|
|
|
// ── Realistic sample stream matrix ─────────────────────────────────────────
|
|
//
|
|
// These tests mirror what real Stremio addon stream objects look like,
|
|
// verifying the full plan output (action, profile, compatibility, safety).
|
|
|
|
#[test]
|
|
fn sample_p5_dvonly_no_fallback() {
|
|
// P5 is HEVC single-layer with no HDR base. Stripping DVCC would expose
|
|
// a DV-only bitstream to an HDR10 decoder → broken colour. Never rewrite.
|
|
let p = plan(r#"{
|
|
"stream": {
|
|
"name": "AETHER | 4K | Dolby Vision | DD+ Atmos",
|
|
"description": "📺 4K | 🎬 dvhe.05.06 | 🔊 DD+ Atmos",
|
|
"dvProfile": 5
|
|
},
|
|
"url": "https://debrid.example/movie.mkv",
|
|
"fallbackMode": "auto",
|
|
"deviceHasDvDecoder": false
|
|
}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "no_hdr_base_layer");
|
|
assert_eq!(p["profile"], "P5");
|
|
let limitations = p["limitations"].as_array().unwrap();
|
|
assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("p4_p5")));
|
|
}
|
|
|
|
#[test]
|
|
fn sample_p7_dual_layer_hdr10_fallback() {
|
|
// P7 BL+EL: stripping DVCC reveals the HDR10 base layer. Medium risk —
|
|
// RPU NALs remain in-stream but HEVC decoders ignore them.
|
|
let p = plan(r#"{
|
|
"stream": {
|
|
"name": "FLUX | 4K | dvhe.07.06 | Atmos",
|
|
"description": "HDR10 + Dolby Vision P7 BL+EL remux",
|
|
"dvProfile": 7
|
|
},
|
|
"url": "https://realdebrid.com/dl/movie2024.mkv",
|
|
"fallbackMode": "auto",
|
|
"deviceHasDvDecoder": false,
|
|
"deviceHasDvDisplay": false
|
|
}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P7");
|
|
assert_eq!(p["compatibility"], "HDR10");
|
|
assert_eq!(p["safety"], "medium");
|
|
let limitations = p["limitations"].as_array().unwrap();
|
|
assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("does_not_convert_bitstream")));
|
|
}
|
|
|
|
#[test]
|
|
fn sample_p8_1_single_layer_low_risk_fallback() {
|
|
// P8.1 has an HDR10-compatible base layer encoded into the single HEVC stream.
|
|
// Stripping DVCC gives clean HDR10 output. Lowest-risk rewrite.
|
|
let p = plan(r#"{
|
|
"stream": {
|
|
"name": "HDMUX | 4K | dvhe.08.01 | TrueHD Atmos",
|
|
"dvProfile": 8,
|
|
"dvCompatId": 1
|
|
},
|
|
"url": "https://debrid.example/Movie.2023.2160p.DV.HEVC.mkv",
|
|
"fallbackMode": "auto",
|
|
"deviceHasDvDecoder": false
|
|
}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P8.1");
|
|
assert_eq!(p["compatibility"], "HDR10");
|
|
assert_eq!(p["safety"], "low");
|
|
}
|
|
|
|
#[test]
|
|
fn sample_p8_4_hlg_base_not_hdr10() {
|
|
// P8.4 has an HLG base layer, not HDR10. Rewriting it as HDR10 would
|
|
// produce incorrect colour. The compatibility field must reflect HLG.
|
|
let p = plan(r#"{
|
|
"stream": {
|
|
"name": "BBC iPlayer | 4K | Dolby Vision HLG | AAC",
|
|
"dvProfile": 8,
|
|
"dvCompatId": 4
|
|
},
|
|
"url": "https://cdn.example/show_ep01.mkv",
|
|
"fallbackMode": "auto",
|
|
"deviceHasDvDecoder": false
|
|
}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
assert_eq!(p["profile"], "P8.4");
|
|
assert_eq!(p["compatibility"], "HLG");
|
|
assert_ne!(p["compatibility"], "HDR10",
|
|
"P8.4 has HLG base, must not be labelled HDR10");
|
|
assert_eq!(p["safety"], "medium");
|
|
}
|
|
|
|
#[test]
|
|
fn sample_unknown_profile_from_addon_with_only_dv_keyword() {
|
|
// Many addons only set a "Dolby Vision" label without specifying the
|
|
// profile. Without profile info the only safe action is none.
|
|
let p = plan(r#"{
|
|
"stream": {
|
|
"name": "4K | Dolby Vision | DD+ Atmos",
|
|
"description": "UHD Remux"
|
|
},
|
|
"url": "https://debrid.example/movie.mkv",
|
|
"fallbackMode": "auto",
|
|
"deviceHasDvDecoder": false
|
|
}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
|
let limitations = p["limitations"].as_array().unwrap();
|
|
assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("set_dvProfile_field")));
|
|
}
|
|
|
|
#[test]
|
|
fn sample_p7_rpu_convert_on_raw_hevc_dv8_mode() {
|
|
// Raw Annex-B HEVC + P7 + dv8 mode → live RPU conversion. The only
|
|
// case where rpu_convert is emitted instead of dvcc_strip.
|
|
let p = plan(r#"{
|
|
"stream": {
|
|
"name": "RAW HEVC | 4K | dvhe.07.06",
|
|
"dvProfile": 7
|
|
},
|
|
"url": "https://cdn.example/stream.hevc",
|
|
"fallbackMode": "dv8",
|
|
"deviceHasDvDecoder": false
|
|
}"#);
|
|
assert_eq!(p["action"], "rpu_convert");
|
|
assert_eq!(p["profile"], "P7");
|
|
assert_eq!(p["compatibility"], "DV8");
|
|
assert_eq!(p["rpuMode"], 2);
|
|
}
|
|
|
|
// ── convert_dv81 mode ──────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn convert_dv81_p7_mkv_decoder_no_display_returns_rpu_convert() {
|
|
// Decoder present, no DV display: MKV now supported via EBML RPU rewriter.
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#);
|
|
assert_eq!(p["action"], "rpu_convert");
|
|
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
|
}
|
|
|
|
#[test]
|
|
fn convert_dv81_p7_mp4_decoder_no_display_returns_rpu_convert() {
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#);
|
|
assert_eq!(p["action"], "rpu_convert");
|
|
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
|
}
|
|
|
|
#[test]
|
|
fn convert_dv81_p7_raw_hevc_decoder_no_display_returns_rpu_convert() {
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.hevc","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#);
|
|
assert_eq!(p["action"], "rpu_convert");
|
|
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
|
}
|
|
|
|
#[test]
|
|
fn convert_dv81_decoder_and_display_returns_native_passthrough() {
|
|
// Full DV device → native, no proxy needed.
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":true}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "hw_dv_decoder");
|
|
}
|
|
|
|
#[test]
|
|
fn convert_dv81_no_decoder_falls_back_to_dvcc_strip() {
|
|
// No DV decoder → same as Auto: strip to HDR10.
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":false,"deviceHasDvDisplay":false}"#);
|
|
assert_eq!(p["action"], "dvcc_strip");
|
|
}
|
|
|
|
#[test]
|
|
fn convert_dv81_hls_still_deferred_to_manifest_rewrite() {
|
|
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/index.m3u8","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "manifest_handled");
|
|
}
|
|
|
|
#[test]
|
|
fn sample_hls_stream_always_deferred_to_manifest_rewrite() {
|
|
// HLS streams are handled by the OkHttp interceptor regardless of profile.
|
|
// The proxy must never be activated for .m3u8 URLs.
|
|
let p = plan(r#"{
|
|
"stream": {
|
|
"name": "Apple TV+ | 4K | dvhe.08.01",
|
|
"dvProfile": 8,
|
|
"dvCompatId": 1
|
|
},
|
|
"url": "https://cdn.example/master.m3u8",
|
|
"fallbackMode": "auto",
|
|
"deviceHasDvDecoder": false
|
|
}"#);
|
|
assert_eq!(p["action"], "none");
|
|
assert_eq!(p["reason"], "manifest_handled");
|
|
}
|
|
}
|