mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-31 03:02:24 +01:00
fast get_series, memory-cache lists, consistent sort order (#952)
* fast get_series, memory-cache lists, consistent sort order - skip series episode details on list decode and in memory cache - serve Xtream lists from memory cache in canonical order - keep sorted index valid after resolve writes - cache verify_full per database identity - resource proxy: single attempt, 15s timeout, 6 fetches per host
This commit is contained in:
@@ -1073,6 +1073,15 @@
|
||||
|
||||
## ⚙️ Optimizations
|
||||
|
||||
- **No full database re-verification on in-place writes.** In-place database writes (stream/series info resolves, probes)
|
||||
no longer re-verify the whole database file when its identity was already verified in this process.
|
||||
|
||||
- **`get_series` no longer decodes episode details for the list.** The series catalog list response only needs
|
||||
the series header fields. Reading stored series items now skips deserializing and interning season and episode details
|
||||
via a scoped decode projection, reducing response latency on large series catalogs. Xtream list endpoints are served
|
||||
from the memory cache when `use_memory_cache` is enabled, and the memory cache no longer holds series season/episode
|
||||
details (series info is read from disk).
|
||||
|
||||
- **Dependency updates.** `jsonwebtoken` 11, `quick-xml` 0.42, `tower-http` 0.7, `tokio-tungstenite` 0.30,
|
||||
`sysinfo` 0.39, `lol_html` 3, `lru` 0.19, `fancy-regex` 0.20 and `gloo-net` 0.7. The vendored `reqwest` moved to
|
||||
0.13.5 with the request-extension forwarding patch re-applied.
|
||||
@@ -1247,6 +1256,17 @@
|
||||
|
||||
## 🐛 Fixes
|
||||
|
||||
- **Image/resource proxy fails fast on upstream errors and limits per-host concurrency.** The image/resource proxy
|
||||
no longer retries a failing upstream image three times (each failing image could block a browser slot for minutes),
|
||||
fails after 15 s waiting for the response (the limit applies to waiting for the response, not to the image transfer),
|
||||
and limits concurrent upstream fetches per host to 6 to avoid provider overload.
|
||||
|
||||
- **Xtream list order stays sorted after stream or series info resolves.** Resolving VOD or series info updates the
|
||||
target database in place, and every commit removed the sorted index, so later list requests returned items in
|
||||
virtual-id order. The Xtream target write path now keeps the index (new or moved keys are merged in) and reads values
|
||||
by primary key while the index is older than the database. Disk and memory cache list the same items in the same
|
||||
`(source_ordinal, virtual_id)` order.
|
||||
|
||||
- **Filters are no longer limited by the parser stack.** A filter that expanded to more than a few dozen terms was
|
||||
rejected with `stack limit reached` on the static-musl build and left the container restarting. The parser dependency
|
||||
no longer probes the stack on musl, and a filter is now stored flat and read, rendered, evaluated and dropped without
|
||||
|
||||
@@ -212,6 +212,8 @@ where
|
||||
/// requests a provider or EPG entry can trigger through one resource link stays bounded.
|
||||
const RESOURCE_REDIRECT_LIMIT: u8 = 5;
|
||||
|
||||
pub(crate) const RESOURCE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How the client-visible answer of a resource route is shaped.
|
||||
///
|
||||
/// The variant mirrors the classification of the destination the route serves: a [`Public`] destination
|
||||
@@ -241,6 +243,7 @@ mod media_server;
|
||||
mod metering;
|
||||
mod provider_acquire;
|
||||
mod provider_urls;
|
||||
mod resource_limiter;
|
||||
mod resources;
|
||||
mod response;
|
||||
mod serialization;
|
||||
@@ -340,3 +343,11 @@ pub(crate) use self::{
|
||||
resolve_stream_user_agent_index, should_allow_exhausted_shared_reconnect, stream_response,
|
||||
},
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::{
|
||||
resource_limiter::{
|
||||
acquire_resource_host_permit, get_resource_host_semaphore, RESOURCE_HOST_CONCURRENCY_LIMIT,
|
||||
RESOURCE_HOST_MAP_PRUNE_THRESHOLD,
|
||||
},
|
||||
resources::fetch_resource_with_timeout,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, LazyLock, Mutex},
|
||||
};
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
|
||||
pub(super) const RESOURCE_HOST_CONCURRENCY_LIMIT: usize = 6;
|
||||
pub(super) const RESOURCE_HOST_MAP_PRUNE_THRESHOLD: usize = 256;
|
||||
|
||||
static RESOURCE_HOST_LIMITERS: LazyLock<Mutex<HashMap<String, Arc<Semaphore>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
pub(super) async fn acquire_resource_host_permit(host: &str) -> Option<OwnedSemaphorePermit> {
|
||||
let semaphore = {
|
||||
let Ok(mut guard) = RESOURCE_HOST_LIMITERS.lock() else {
|
||||
return None;
|
||||
};
|
||||
if let Some(semaphore) = guard.get(host) {
|
||||
semaphore.clone()
|
||||
} else {
|
||||
if guard.len() > RESOURCE_HOST_MAP_PRUNE_THRESHOLD {
|
||||
guard.retain(|_, semaphore| Arc::strong_count(semaphore) > 1);
|
||||
}
|
||||
let semaphore = Arc::new(Semaphore::new(RESOURCE_HOST_CONCURRENCY_LIMIT));
|
||||
guard.insert(host.to_string(), Arc::clone(&semaphore));
|
||||
semaphore
|
||||
}
|
||||
};
|
||||
semaphore.acquire_owned().await.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn get_resource_host_semaphore(host: &str) -> Option<Arc<Semaphore>> {
|
||||
RESOURCE_HOST_LIMITERS.lock().ok()?.get(host).cloned()
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
use super::{
|
||||
force_stream_response, get_headers_from_request, get_hls_playback_ttl_secs, is_hop_by_hop_response_header,
|
||||
media_server_image_error_status, open_media_server_image_resource, redirect, serve_file, try_unwrap_body,
|
||||
ForceStreamRequestContext, HeaderFilter, ResourceFetchPolicy, StreamResponseMode, RESOURCE_REDIRECT_LIMIT,
|
||||
media_server_image_error_status, open_media_server_image_resource, redirect,
|
||||
resource_limiter::acquire_resource_host_permit, serve_file, try_unwrap_body, ForceStreamRequestContext,
|
||||
HeaderFilter, ResourceFetchPolicy, StreamResponseMode, RESOURCE_ATTEMPT_TIMEOUT, RESOURCE_REDIRECT_LIMIT,
|
||||
};
|
||||
use crate::{
|
||||
api::model::{tee_stream, AppState, StreamDetails, StreamError, UserSession},
|
||||
auth::Fingerprint,
|
||||
model::{ConfigInput, PlaybackKind},
|
||||
utils::{
|
||||
async_file_writer, classify_output_resource_url, classify_resource_hop, create_new_file_for_write,
|
||||
debug_if_enabled, request,
|
||||
request::{classify_resource_destination, send_with_retry_and_provider, ResourceDestination},
|
||||
async_file_writer, classify_output_resource_url, classify_resource_hop, create_new_file_for_write, request,
|
||||
request::{
|
||||
classify_resource_destination, send_with_retry_and_provider_policy_with_options, RequestFetchOptions,
|
||||
ResourceDestination,
|
||||
},
|
||||
trace_if_enabled, LRUResourceCache, ResourceHop,
|
||||
},
|
||||
};
|
||||
@@ -25,7 +28,7 @@ use shared::{
|
||||
model::{InputFetchMethod, PlaylistItemType, ResourceOutputPolicy, StreamChannel},
|
||||
utils::sanitize_sensitive_info,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use tokio::sync::RwLock;
|
||||
use url::Url;
|
||||
|
||||
@@ -185,6 +188,28 @@ pub(super) async fn fetch_resource_with_retry(
|
||||
resource_url: &str,
|
||||
req_headers: &HashMap<String, Vec<u8>>,
|
||||
input: Option<&ConfigInput>,
|
||||
) -> Option<axum::response::Response> {
|
||||
fetch_resource_with_timeout(
|
||||
app_state,
|
||||
url,
|
||||
fetch_policy,
|
||||
resource_url,
|
||||
req_headers,
|
||||
input,
|
||||
RESOURCE_ATTEMPT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(super) async fn fetch_resource_with_timeout(
|
||||
app_state: &Arc<AppState>,
|
||||
url: &Url,
|
||||
fetch_policy: ResourceFetchPolicy,
|
||||
resource_url: &str,
|
||||
req_headers: &HashMap<String, Vec<u8>>,
|
||||
input: Option<&ConfigInput>,
|
||||
attempt_timeout: Duration,
|
||||
) -> Option<axum::response::Response> {
|
||||
let cache_key = fetch_policy.cache_key(resource_url);
|
||||
let config = app_state.app_config.config.load();
|
||||
@@ -196,6 +221,9 @@ pub(super) async fn fetch_resource_with_retry(
|
||||
let mut current_headers = req_headers.clone();
|
||||
let mut current_input = input;
|
||||
let mut method = input.map_or(InputFetchMethod::GET, |i| i.method);
|
||||
let fetch_options = RequestFetchOptions::with_attempt_idle_timeout(attempt_timeout)
|
||||
.without_resource_retries()
|
||||
.with_header_timeout_only();
|
||||
|
||||
for redirects in 0..=RESOURCE_REDIRECT_LIMIT {
|
||||
// Every hop is routed on its own, so a redirect cannot move a request to an egress the hop
|
||||
@@ -207,13 +235,17 @@ pub(super) async fn fetch_resource_with_retry(
|
||||
}
|
||||
let use_proxy_aware_client = hop == ResourceHop::ViaProxy;
|
||||
let provider_config = current_input.and_then(|i| i.get_resolve_provider(current_url.as_str()));
|
||||
let response = match send_with_retry_and_provider(
|
||||
let permit =
|
||||
if let Some(host) = current_url.host_str() { acquire_resource_host_permit(host).await } else { None };
|
||||
let result = send_with_retry_and_provider_policy_with_options(
|
||||
&app_state.app_config,
|
||||
¤t_url,
|
||||
provider_config.as_ref(),
|
||||
// Hand redirects back instead of retrying them: the loop below follows them itself, so that
|
||||
// every hop is classified and no client-side redirect policy decides where the request ends.
|
||||
true,
|
||||
true,
|
||||
fetch_options,
|
||||
|resolved_url| {
|
||||
let http_client = if use_proxy_aware_client {
|
||||
app_state.http_clients.resource_public_no_redirect.load()
|
||||
@@ -231,8 +263,19 @@ pub(super) async fn fetch_resource_with_retry(
|
||||
)
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
drop(permit);
|
||||
let response = match result {
|
||||
// The single-attempt path hands HTTP error statuses back as responses; treat them as a failed
|
||||
// fetch, as the retrying path did.
|
||||
Ok(response) if response.status().is_client_error() || response.status().is_server_error() => {
|
||||
debug!(
|
||||
"Resource fetch failed for {}: status {}",
|
||||
sanitize_sensitive_info(resource_url),
|
||||
response.status()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
@@ -279,18 +322,7 @@ pub(super) async fn fetch_resource_with_retry(
|
||||
);
|
||||
}
|
||||
|
||||
debug_if_enabled!("Failed to open resource got status {status} for {}", sanitize_sensitive_info(resource_url));
|
||||
if !fetch_policy.relays_upstream_response() {
|
||||
return Some(status.into_response());
|
||||
}
|
||||
let mut response_builder = axum::response::Response::builder().status(status);
|
||||
for (key, value) in response.headers() {
|
||||
if !is_hop_by_hop_response_header(key) {
|
||||
response_builder = response_builder.header(key, value);
|
||||
}
|
||||
}
|
||||
let stream = response.bytes_stream().map_err(|err| StreamError::reqwest(&err));
|
||||
return Some(try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(stream))));
|
||||
return None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1186,14 +1186,6 @@ impl ResourceFetchPolicy {
|
||||
Self::NonPublic => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an upstream response may be relayed to the client.
|
||||
///
|
||||
/// A public destination was chosen by the user's own configuration, so what it answered is what
|
||||
/// the client asked for. A resource proxy whose destination is deliberately hidden from the client
|
||||
/// must not become a reader for that destination: status, headers and body of an upstream error can
|
||||
/// disclose more about the internal service than the link itself.
|
||||
pub(super) const fn relays_upstream_response(self) -> bool { matches!(self, Self::Public) }
|
||||
}
|
||||
|
||||
pub fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> bool {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::{
|
||||
create_test_app_state, media_server_image_error_status, resource_input_for_url, resource_proxy_response,
|
||||
resource_redirect_or_proxy, resource_response, spawn_legacy_hls_test_origin, ResourceFetchPolicy,
|
||||
acquire_resource_host_permit, create_test_app_state, fetch_resource_with_timeout, get_resource_host_semaphore,
|
||||
media_server_image_error_status, resource_input_for_url, resource_proxy_response, resource_redirect_or_proxy,
|
||||
resource_response, spawn_legacy_hls_test_origin, ResourceFetchPolicy, RESOURCE_HOST_CONCURRENCY_LIMIT,
|
||||
RESOURCE_HOST_MAP_PRUNE_THRESHOLD,
|
||||
};
|
||||
use crate::{
|
||||
media_server::{MediaServerError, MediaServerErrorKind},
|
||||
@@ -13,12 +15,21 @@ use axum::{
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
use std::{fmt::Write as _, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::Write as _,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
sync::RwLock,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
#[test]
|
||||
fn media_server_image_error_status_classifies_client_and_upstream_failures() {
|
||||
@@ -441,3 +452,228 @@ async fn resource_client_refuses_names_that_resolve_to_local_addresses() {
|
||||
}
|
||||
assert!(causes.contains("local to this host"), "{causes}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upstream_502_fails_fast_without_retrying() {
|
||||
let app_state = create_test_app_state();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("mock origin binds");
|
||||
let origin_addr = listener.local_addr().expect("mock origin address");
|
||||
let request_count = Arc::new(AtomicUsize::new(0));
|
||||
let request_count_task = Arc::clone(&request_count);
|
||||
|
||||
let origin_task = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok(accepted) = tokio::time::timeout(Duration::from_millis(500), listener.accept()).await else {
|
||||
break;
|
||||
};
|
||||
let Ok((mut socket, _)) = accepted else { break };
|
||||
request_count_task.fetch_add(1, Ordering::SeqCst);
|
||||
let mut request = Vec::new();
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let Ok(read) = socket.read(&mut chunk).await else { break };
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
let head = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(head.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(reqwest::Proxy::all(format!("http://{origin_addr}")).expect("test proxy"))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("mock client");
|
||||
app_state.http_clients.resource_public_no_redirect.store(Arc::new(client));
|
||||
|
||||
let response =
|
||||
resource_response(&app_state, ResourceFetchPolicy::Public, "http://8.8.8.8/icon.png", &HeaderMap::new(), None)
|
||||
.await
|
||||
.into_response();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
let _ = origin_task.await;
|
||||
assert_eq!(request_count.load(Ordering::SeqCst), 1, "failing upstream resource request must not be retried");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unresponsive_upstream_fails_after_attempt_timeout() {
|
||||
let app_state = create_test_app_state();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("mock origin binds");
|
||||
let origin_addr = listener.local_addr().expect("mock origin address");
|
||||
|
||||
let origin_task = tokio::spawn(async move {
|
||||
if let Ok((mut socket, _)) = listener.accept().await {
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let _ = socket.read(&mut chunk).await;
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(reqwest::Proxy::all(format!("http://{origin_addr}")).expect("test proxy"))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("mock client");
|
||||
app_state.http_clients.resource_public_no_redirect.store(Arc::new(client));
|
||||
|
||||
let url = Url::parse("http://8.8.8.8/unresponsive.png").expect("valid url");
|
||||
let start = std::time::Instant::now();
|
||||
let resp = fetch_resource_with_timeout(
|
||||
&app_state,
|
||||
&url,
|
||||
ResourceFetchPolicy::Public,
|
||||
"http://8.8.8.8/unresponsive.png",
|
||||
&HashMap::new(),
|
||||
None,
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(resp.is_none(), "fetch must fail after attempt timeout");
|
||||
assert!(start.elapsed() < Duration::from_secs(5), "fetch should time out quickly based on attempt timeout");
|
||||
origin_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_fetch_timeout_covers_headers_only_and_delivers_delayed_body() {
|
||||
let app_state = create_test_app_state();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("mock origin binds");
|
||||
let origin_addr = listener.local_addr().expect("mock origin address");
|
||||
|
||||
let origin_task = tokio::spawn(async move {
|
||||
if let Ok((mut socket, _)) = listener.accept().await {
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let _ = socket.read(&mut chunk).await;
|
||||
let response_headers =
|
||||
b"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 10\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(response_headers).await;
|
||||
let _ = socket.flush().await;
|
||||
// Delay body longer than the attempt timeout (100ms attempt timeout, delay 250ms)
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
let _ = socket.write_all(b"0123456789").await;
|
||||
let _ = socket.flush().await;
|
||||
}
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(reqwest::Proxy::all(format!("http://{origin_addr}")).expect("test proxy"))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("mock client");
|
||||
app_state.http_clients.resource_public_no_redirect.store(Arc::new(client));
|
||||
|
||||
let url = Url::parse("http://8.8.8.8/delayed_body.png").expect("valid url");
|
||||
let resp = fetch_resource_with_timeout(
|
||||
&app_state,
|
||||
&url,
|
||||
ResourceFetchPolicy::Public,
|
||||
"http://8.8.8.8/delayed_body.png",
|
||||
&HashMap::new(),
|
||||
None,
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(resp.is_some(), "fetch must succeed when headers arrive immediately");
|
||||
let response = resp.expect("valid response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body_bytes = response.into_body().collect().await.expect("reading response body must succeed").to_bytes();
|
||||
assert_eq!(&body_bytes[..], b"0123456789", "full body must be delivered despite body delay");
|
||||
origin_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_limiter_blocks_excess_permits_on_same_host_without_blocking_other_hosts() {
|
||||
use futures::FutureExt as _;
|
||||
use std::pin::pin;
|
||||
|
||||
let host_a = "limiter-host-a.invalid";
|
||||
let host_b = "limiter-host-b.invalid";
|
||||
|
||||
let mut permits_a = Vec::new();
|
||||
for _ in 0..RESOURCE_HOST_CONCURRENCY_LIMIT {
|
||||
let permit = acquire_resource_host_permit(host_a).await;
|
||||
assert!(permit.is_some(), "must acquire permit up to capacity");
|
||||
permits_a.push(permit.expect("permit"));
|
||||
}
|
||||
|
||||
// Another host is not blocked
|
||||
let permit_b = acquire_resource_host_permit(host_b).await;
|
||||
assert!(permit_b.is_some(), "another host must not be blocked when host A is at capacity");
|
||||
|
||||
// With all permits taken for host A, the next acquire waits
|
||||
let mut next_acquire = pin!(acquire_resource_host_permit(host_a));
|
||||
assert!(next_acquire.as_mut().now_or_never().is_none(), "next acquire on host A must wait");
|
||||
|
||||
// Release one permit
|
||||
drop(permits_a.pop());
|
||||
|
||||
// Now it completes
|
||||
let acquired = tokio::time::timeout(Duration::from_secs(1), next_acquire)
|
||||
.await
|
||||
.expect("must resolve after permit is released");
|
||||
assert!(acquired.is_some(), "must acquire permit once one is released");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_limiter_prunes_idle_entries_above_threshold_while_keeping_active_host() {
|
||||
use futures::FutureExt as _;
|
||||
use std::pin::pin;
|
||||
|
||||
let active_host = "limiter-prune-active.invalid";
|
||||
let held_permit = acquire_resource_host_permit(active_host).await;
|
||||
assert!(held_permit.is_some(), "must acquire permit on active host");
|
||||
let active_semaphore = get_resource_host_semaphore(active_host).expect("active host semaphore must exist");
|
||||
|
||||
for i in 0..=RESOURCE_HOST_MAP_PRUNE_THRESHOLD {
|
||||
let idle_host = format!("limiter-prune-idle-{i}.invalid");
|
||||
let permit = acquire_resource_host_permit(&idle_host).await;
|
||||
assert!(permit.is_some(), "must acquire permit on idle host");
|
||||
drop(permit);
|
||||
}
|
||||
|
||||
let new_host = "limiter-prune-new.invalid";
|
||||
let new_permit = acquire_resource_host_permit(new_host).await;
|
||||
assert!(new_permit.is_some(), "must acquire permit on new host");
|
||||
|
||||
// Idle host was pruned
|
||||
assert!(
|
||||
get_resource_host_semaphore("limiter-prune-idle-0.invalid").is_none(),
|
||||
"idle entries above threshold must be pruned"
|
||||
);
|
||||
|
||||
// Active host kept its semaphore (same Arc and permits still counted)
|
||||
let current_active_semaphore =
|
||||
get_resource_host_semaphore(active_host).expect("active host semaphore must still be present");
|
||||
assert!(Arc::ptr_eq(&active_semaphore, ¤t_active_semaphore), "active host must keep the same semaphore Arc");
|
||||
assert_eq!(
|
||||
current_active_semaphore.available_permits(),
|
||||
RESOURCE_HOST_CONCURRENCY_LIMIT - 1,
|
||||
"active host permit count must still reflect held permit"
|
||||
);
|
||||
|
||||
// Remaining capacity on active host can be acquired
|
||||
let mut more_permits = Vec::new();
|
||||
for _ in 0..(RESOURCE_HOST_CONCURRENCY_LIMIT - 1) {
|
||||
let permit = acquire_resource_host_permit(active_host).await;
|
||||
assert!(permit.is_some(), "must acquire remaining capacity on active host");
|
||||
more_permits.push(permit.expect("permit"));
|
||||
}
|
||||
|
||||
// Now active host is saturated and waits
|
||||
let mut next_acquire = pin!(acquire_resource_host_permit(active_host));
|
||||
assert!(next_acquire.as_mut().now_or_never().is_none(), "next acquire on active host must wait");
|
||||
|
||||
drop(held_permit);
|
||||
let acquired = tokio::time::timeout(Duration::from_secs(1), next_acquire)
|
||||
.await
|
||||
.expect("must resolve after permit is released");
|
||||
assert!(acquired.is_some(), "must acquire permit once one is released");
|
||||
drop(new_permit);
|
||||
drop(more_permits);
|
||||
}
|
||||
|
||||
@@ -322,24 +322,24 @@ async fn lineup_status(
|
||||
0
|
||||
}
|
||||
} else if target.has_output(TargetType::Xtream) {
|
||||
let cred = Arc::clone(&credentials);
|
||||
let live = if cred.allows_cluster(XtreamCluster::Live) {
|
||||
match XtreamPlaylistIterator::new(XtreamCluster::Live, &cfg, &target, None, &cred).await {
|
||||
Ok(stream) => stream.filter_map(|entry| async move { entry.ok() }).count().await,
|
||||
Err(_) => 0,
|
||||
let mut count = 0;
|
||||
for cluster in [XtreamCluster::Live, XtreamCluster::Video] {
|
||||
if credentials.allows_cluster(cluster) {
|
||||
if let Ok(stream) = XtreamPlaylistIterator::new(
|
||||
cluster,
|
||||
&cfg,
|
||||
&app_state.app_state.playlists,
|
||||
&target,
|
||||
None,
|
||||
&credentials,
|
||||
)
|
||||
.await
|
||||
{
|
||||
count += stream.filter_map(|entry| async move { entry.ok() }).count().await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let vod = if cred.allows_cluster(XtreamCluster::Video) {
|
||||
match XtreamPlaylistIterator::new(XtreamCluster::Video, &cfg, &target, None, &cred).await {
|
||||
Ok(stream) => stream.filter_map(|entry| async move { entry.ok() }).count().await,
|
||||
Err(_) => 0,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
live + vod
|
||||
}
|
||||
count
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -416,12 +416,30 @@ async fn lineup(
|
||||
let (base_url_live, base_url_vod) = xtream_base_urls(credentials, target, server_info.get_base_url());
|
||||
|
||||
let live_channels = if credentials.allows_cluster(XtreamCluster::Live) {
|
||||
XtreamPlaylistIterator::new(XtreamCluster::Live, cfg, target, None, credentials).await.ok()
|
||||
XtreamPlaylistIterator::new(
|
||||
XtreamCluster::Live,
|
||||
cfg,
|
||||
&app_state.app_state.playlists,
|
||||
target,
|
||||
None,
|
||||
credentials,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let vod_channels = if credentials.allows_cluster(XtreamCluster::Video) {
|
||||
XtreamPlaylistIterator::new(XtreamCluster::Video, cfg, target, None, credentials).await.ok()
|
||||
XtreamPlaylistIterator::new(
|
||||
XtreamCluster::Video,
|
||||
cfg,
|
||||
&app_state.app_state.playlists,
|
||||
target,
|
||||
None,
|
||||
credentials,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -783,7 +801,9 @@ async fn resolve_channel_stream_url(
|
||||
if !credentials.allows_cluster(cluster) {
|
||||
continue;
|
||||
}
|
||||
let Ok(channels) = XtreamPlaylistIterator::new(cluster, cfg, target, None, credentials).await else {
|
||||
let Ok(channels) =
|
||||
XtreamPlaylistIterator::new(cluster, cfg, &app_state.playlists, target, None, credentials).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some((rank, item)) = find_channel(channels, query).await else { continue };
|
||||
|
||||
@@ -142,7 +142,7 @@ pub(in crate::api::endpoints::hls_api::tests) async fn cache_recording_test_item
|
||||
.playlists
|
||||
.cache_playlist(
|
||||
&target.name,
|
||||
PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage { live, vod, series })),
|
||||
PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage::from_trees(live, vod, series))),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
|
||||
@@ -1876,17 +1876,39 @@ async fn xtream_player_api(
|
||||
let result = match action {
|
||||
crate::model::XC_ACTION_GET_LIVE_STREAMS => skip_flag_optional!(
|
||||
skip_live,
|
||||
xtream_load_rewrite_playlist(XtreamCluster::Live, &app_state.app_config, &target, category_id, &user).await
|
||||
xtream_load_rewrite_playlist(
|
||||
XtreamCluster::Live,
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
&target,
|
||||
category_id,
|
||||
&user
|
||||
)
|
||||
.await
|
||||
),
|
||||
crate::model::XC_ACTION_GET_VOD_STREAMS => skip_flag_optional!(
|
||||
skip_vod,
|
||||
xtream_load_rewrite_playlist(XtreamCluster::Video, &app_state.app_config, &target, category_id, &user)
|
||||
.await
|
||||
xtream_load_rewrite_playlist(
|
||||
XtreamCluster::Video,
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
&target,
|
||||
category_id,
|
||||
&user
|
||||
)
|
||||
.await
|
||||
),
|
||||
crate::model::XC_ACTION_GET_SERIES => skip_flag_optional!(
|
||||
skip_series,
|
||||
xtream_load_rewrite_playlist(XtreamCluster::Series, &app_state.app_config, &target, category_id, &user)
|
||||
.await
|
||||
xtream_load_rewrite_playlist(
|
||||
XtreamCluster::Series,
|
||||
&app_state.app_config,
|
||||
&app_state.playlists,
|
||||
&target,
|
||||
category_id,
|
||||
&user
|
||||
)
|
||||
.await
|
||||
),
|
||||
_ => Some(Err(TuliproxError::ApiXtream(format!("Unknown api call: {action} for target: {}", target.name)))),
|
||||
};
|
||||
|
||||
@@ -201,32 +201,7 @@ async fn xtream_short_epg_returns_imported_ics_programme_for_matching_channel_id
|
||||
.expect("write target EPG");
|
||||
|
||||
let app_state = create_test_app_state(config);
|
||||
let live_item = short_epg_live_item();
|
||||
let mut live = BPlusTree::new();
|
||||
live.insert(live_item.virtual_id.get(), live_item.clone());
|
||||
app_state
|
||||
.playlists
|
||||
.cache_playlist(
|
||||
&target.name,
|
||||
PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage {
|
||||
live,
|
||||
vod: BPlusTree::new(),
|
||||
series: BPlusTree::new(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let mut id_mapping = BPlusTree::new();
|
||||
id_mapping.insert(
|
||||
live_item.virtual_id,
|
||||
VirtualIdRecord::new(
|
||||
live_item.provider_id,
|
||||
live_item.virtual_id,
|
||||
PlaylistItemType::Live,
|
||||
VirtualId::new(0),
|
||||
UUIDType::default(),
|
||||
),
|
||||
);
|
||||
app_state.playlists.cache_id_mapping(&target.name, id_mapping).await;
|
||||
cache_xtream_test_item(&app_state, &target, short_epg_live_item(), None).await;
|
||||
|
||||
let mut user = ProxyUserCredentials::default();
|
||||
user.output_clusters = ClusterFlags::all();
|
||||
@@ -303,11 +278,11 @@ async fn cache_xtream_test_item(
|
||||
.playlists
|
||||
.cache_playlist(
|
||||
&target.name,
|
||||
PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage {
|
||||
PlaylistStorage::XtreamPlaylist(Box::new(PlaylistXtreamStorage::from_trees(
|
||||
live,
|
||||
vod: BPlusTree::new(),
|
||||
series: BPlusTree::new(),
|
||||
})),
|
||||
BPlusTree::new(),
|
||||
BPlusTree::new(),
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
let mut id_mapping = BPlusTree::new();
|
||||
|
||||
@@ -44,6 +44,8 @@ pub use recovery::{
|
||||
RecoveryRepositoryState, RecoverySchema, RecoveryStoragePlacement, RecoveryVerificationReport,
|
||||
};
|
||||
// --- Sorted-index sidecar --------------------------------------------------
|
||||
/// Snapshot of a sorted-index sidecar that the database writer rewrites after its own commit.
|
||||
pub use sorted_index::v4::IndexSnapshot;
|
||||
/// Iterates a database in sorted-index order, falling back to the caller's own
|
||||
/// handling when the sidecar is unusable.
|
||||
pub use sorted_index::v4::OwnedIterator as SortedIndexIterator;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod v4 {
|
||||
use crate::{
|
||||
codec::{binary_deserialize, binary_serialize},
|
||||
v3::{BPlusTreeQuery, Locator},
|
||||
v3::{publish_database, sync_parent_directory, temporary_path, BPlusTreeQuery, Locator},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -28,7 +28,7 @@ pub mod v4 {
|
||||
|
||||
fn invalid_integer(error: TryFromIntError) -> io::Error { invalid_data(error.to_string()) }
|
||||
|
||||
fn header(database_id: [u8; 16], generation: u64, count: u64) -> [u8; HEADER_LEN] {
|
||||
pub(crate) fn header(database_id: [u8; 16], generation: u64, count: u64) -> [u8; HEADER_LEN] {
|
||||
let mut bytes = [0; HEADER_LEN];
|
||||
bytes[0..4].copy_from_slice(MAGIC);
|
||||
bytes[4..8].copy_from_slice(&VERSION.to_le_bytes());
|
||||
@@ -58,11 +58,14 @@ pub mod v4 {
|
||||
.ok_or_else(|| invalid_data("sorted-index u64 field is truncated"))
|
||||
}
|
||||
|
||||
fn decode_header(
|
||||
mut bytes: [u8; HEADER_LEN],
|
||||
expected_database_id: [u8; 16],
|
||||
expected_generation: u64,
|
||||
) -> io::Result<u64> {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct HeaderIdentity {
|
||||
database_id: [u8; 16],
|
||||
generation: u64,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
fn decode_header_identity(mut bytes: [u8; HEADER_LEN]) -> io::Result<HeaderIdentity> {
|
||||
if bytes[0..4] != *MAGIC
|
||||
|| read_u32(&bytes, 4)? != VERSION
|
||||
|| read_u32(&bytes, 8)? != TREE_VERSION
|
||||
@@ -78,10 +81,27 @@ pub mod v4 {
|
||||
if crc32fast::hash(&bytes) != expected_checksum {
|
||||
return Err(invalid_data("sorted-index header checksum mismatch"));
|
||||
}
|
||||
if bytes[16..32] != expected_database_id || read_u64(&bytes, 32)? != expected_generation {
|
||||
return Err(invalid_data("sorted-index tree identity or generation mismatch"));
|
||||
let mut database_id = [0u8; 16];
|
||||
database_id.copy_from_slice(&bytes[16..32]);
|
||||
let generation = read_u64(&bytes, 32)?;
|
||||
let count = read_u64(&bytes, 40)?;
|
||||
Ok(HeaderIdentity { database_id, generation, count })
|
||||
}
|
||||
|
||||
fn decode_header(
|
||||
bytes: [u8; HEADER_LEN],
|
||||
expected_database_id: [u8; 16],
|
||||
expected_generation: u64,
|
||||
) -> io::Result<(u64, bool)> {
|
||||
let identity = decode_header_identity(bytes)?;
|
||||
if identity.database_id != expected_database_id {
|
||||
return Err(invalid_data("sorted-index tree identity mismatch"));
|
||||
}
|
||||
read_u64(&bytes, 40)
|
||||
if identity.generation > expected_generation {
|
||||
return Err(invalid_data("sorted-index generation is newer than tree generation"));
|
||||
}
|
||||
let is_stale = identity.generation < expected_generation;
|
||||
Ok((identity.count, is_stale))
|
||||
}
|
||||
|
||||
pub struct Writer<SortKey, K> {
|
||||
@@ -153,6 +173,7 @@ pub mod v4 {
|
||||
remaining_entries: u64,
|
||||
remaining_bytes: u64,
|
||||
failed: bool,
|
||||
stale: bool,
|
||||
body: Vec<u8>,
|
||||
_marker: PhantomData<(SortKey, K)>,
|
||||
}
|
||||
@@ -171,17 +192,26 @@ pub mod v4 {
|
||||
let mut input = BufReader::new(file);
|
||||
let mut encoded = [0; HEADER_LEN];
|
||||
input.read_exact(&mut encoded)?;
|
||||
let remaining_entries = decode_header(encoded, expected_database_id, expected_generation)?;
|
||||
Ok(Self {
|
||||
let (remaining_entries, stale) = decode_header(encoded, expected_database_id, expected_generation)?;
|
||||
let remaining_bytes = length - HEADER_LEN as u64;
|
||||
Ok(Self::from_reader(input, remaining_bytes, remaining_entries, stale))
|
||||
}
|
||||
|
||||
fn from_reader(input: BufReader<File>, remaining_bytes: u64, remaining_entries: u64, stale: bool) -> Self {
|
||||
Self {
|
||||
input,
|
||||
remaining_entries,
|
||||
remaining_bytes: length - HEADER_LEN as u64,
|
||||
remaining_bytes,
|
||||
failed: false,
|
||||
stale,
|
||||
body: Vec::new(),
|
||||
_marker: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_stale(&self) -> bool { self.stale }
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn remaining(&self) -> u64 { self.remaining_entries }
|
||||
|
||||
@@ -278,6 +308,7 @@ pub mod v4 {
|
||||
reader: Reader<SortKey, K>,
|
||||
query: BPlusTreeQuery<K, V>,
|
||||
previous_sort_key: Option<SortKey>,
|
||||
is_stale: bool,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
@@ -290,7 +321,8 @@ pub mod v4 {
|
||||
pub fn open(query: BPlusTreeQuery<K, V>, index_path: &Path) -> io::Result<Self> {
|
||||
let (database_id, generation) = query.snapshot_identity();
|
||||
let reader = Reader::open(index_path, database_id, generation)?;
|
||||
Ok(Self { reader, query, previous_sort_key: None, finished: false })
|
||||
let is_stale = reader.is_stale();
|
||||
Ok(Self { reader, query, previous_sort_key: None, is_stale, finished: false })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -309,30 +341,140 @@ pub mod v4 {
|
||||
if self.finished {
|
||||
return None;
|
||||
}
|
||||
let result = match self.reader.read_next_borrowed() {
|
||||
Ok(Some(entry)) => {
|
||||
if self.previous_sort_key.as_ref().is_some_and(|previous| previous > &entry.sort_key) {
|
||||
Err(invalid_data("sorted-index entries are out of order"))
|
||||
} else {
|
||||
let BorrowedEntry { sort_key, primary_key, serialized_primary_key, locator } = entry;
|
||||
self.previous_sort_key = Some(sort_key);
|
||||
self.query.read_locator_value(locator, serialized_primary_key).map(|value| (primary_key, value))
|
||||
loop {
|
||||
let entry = match self.reader.read_next_borrowed() {
|
||||
Ok(Some(entry)) => entry,
|
||||
Ok(None) => {
|
||||
self.finished = true;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
Err(error) => {
|
||||
self.finished = true;
|
||||
return Some(Err(error));
|
||||
}
|
||||
};
|
||||
|
||||
if self.previous_sort_key.as_ref().is_some_and(|previous| previous > &entry.sort_key) {
|
||||
self.finished = true;
|
||||
return None;
|
||||
return Some(Err(invalid_data("sorted-index entries are out of order")));
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
match result {
|
||||
Ok(entry) => Some(Ok(entry)),
|
||||
Err(error) => Some(Err(error)),
|
||||
|
||||
let BorrowedEntry { sort_key, primary_key, serialized_primary_key, locator } = entry;
|
||||
self.previous_sort_key = Some(sort_key);
|
||||
|
||||
if self.is_stale {
|
||||
match self.query.query_io(&primary_key) {
|
||||
Ok(Some(value)) => return Some(Ok((primary_key, value))),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
self.finished = true;
|
||||
return Some(Err(error));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let result =
|
||||
self.query.read_locator_value(locator, serialized_primary_key).map(|value| (primary_key, value));
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory snapshot of a sorted index for read-modify-write workflows.
|
||||
///
|
||||
/// Every `BPlusTreeUpdate` commit deletes the sorted index file. Therefore, a stale
|
||||
/// index only exists when the caller holding the database write lock rewrote it via
|
||||
/// `write_merged` right after its own commit, and it must contain every key of the tree.
|
||||
#[derive(Debug)]
|
||||
pub struct IndexSnapshot<SortKey, K> {
|
||||
database_id: [u8; 16],
|
||||
generation: u64,
|
||||
entries: Vec<(SortKey, K, Locator)>,
|
||||
}
|
||||
|
||||
impl<SortKey, K> IndexSnapshot<SortKey, K>
|
||||
where
|
||||
SortKey: Ord + Serialize + for<'de> Deserialize<'de> + Clone,
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone,
|
||||
{
|
||||
pub fn read(index_path: &Path) -> io::Result<Option<Self>> {
|
||||
let file = match File::open(index_path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let length = file.metadata()?.len();
|
||||
if length < HEADER_LEN as u64 {
|
||||
return Err(invalid_data("sorted-index header is truncated"));
|
||||
}
|
||||
let mut input = BufReader::new(file);
|
||||
let mut encoded = [0; HEADER_LEN];
|
||||
input.read_exact(&mut encoded)?;
|
||||
let identity = decode_header_identity(encoded)?;
|
||||
let remaining_bytes = length - HEADER_LEN as u64;
|
||||
let min_entry_len = (ENTRY_PREFIX_LEN + ENTRY_FIXED_BODY_LEN) as u64;
|
||||
if identity.count > remaining_bytes / min_entry_len {
|
||||
return Err(invalid_data("sorted-index count exceeds remaining file bytes"));
|
||||
}
|
||||
let mut reader = Reader::<SortKey, K>::from_reader(input, remaining_bytes, identity.count, false);
|
||||
let count = usize::try_from(identity.count).map_err(invalid_integer)?;
|
||||
let mut entries = Vec::with_capacity(count);
|
||||
while let Some(entry) = reader.read_next_borrowed()? {
|
||||
entries.push((entry.sort_key, entry.primary_key, entry.locator));
|
||||
}
|
||||
Ok(Some(Self { database_id: identity.database_id, generation: identity.generation, entries }))
|
||||
}
|
||||
|
||||
pub fn write_merged(mut self, index_path: &Path, changed: &[(SortKey, K)]) -> io::Result<()> {
|
||||
let mut changed_map = std::collections::BTreeMap::new();
|
||||
for (sort_key, primary_key) in changed {
|
||||
changed_map.insert(primary_key, sort_key);
|
||||
}
|
||||
if !changed_map.is_empty() {
|
||||
self.entries.retain(|(_, primary_key, _)| !changed_map.contains_key(primary_key));
|
||||
let dummy_locator = Locator { leaf_page_id: 1, slot_index: 0, serialized_key_crc32: 0 };
|
||||
for (primary_key, sort_key) in changed_map {
|
||||
self.entries.push((sort_key.clone(), primary_key.clone(), dummy_locator));
|
||||
}
|
||||
self.entries.sort_unstable_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
|
||||
}
|
||||
|
||||
publish_index(index_path, self.database_id, self.generation, &self.entries)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn publish_index<'a, SortKey, K>(
|
||||
destination: &Path,
|
||||
database_id: [u8; 16],
|
||||
generation: u64,
|
||||
entries: impl IntoIterator<Item = &'a (SortKey, K, Locator)>,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
SortKey: Serialize + 'a,
|
||||
K: Serialize + 'a,
|
||||
{
|
||||
let temporary = temporary_path(destination)?;
|
||||
let write_result = (|| -> io::Result<()> {
|
||||
let mut writer = Writer::<SortKey, K>::new(&temporary, database_id, generation)?;
|
||||
for (sort_key, primary_key, locator) in entries {
|
||||
writer.push(sort_key, primary_key, *locator)?;
|
||||
}
|
||||
writer.finish()?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(error) = write_result {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
publish_database(&temporary, destination, sync_parent_directory).map_err(io::Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use v4::publish_index;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -370,7 +512,120 @@ mod tests {
|
||||
assert_eq!(entry.locator, locator);
|
||||
assert!(reader.read_next()?.is_none());
|
||||
assert!(v4::Reader::<String, u32>::open(&path, [0; 16], 9).is_err());
|
||||
assert!(v4::Reader::<String, u32>::open(&path, database_id, 10).is_err());
|
||||
assert!(v4::Reader::<String, u32>::open(&path, database_id, 8).is_err());
|
||||
let stale_reader = v4::Reader::<String, u32>::open(&path, database_id, 10)?;
|
||||
assert!(stale_reader.is_stale());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_generation_by_key_read_skips_missing_keys_and_returns_sorted() -> io::Result<()> {
|
||||
use crate::BPlusTreeUpdate;
|
||||
|
||||
let dir = tempdir()?;
|
||||
let database = dir.path().join("stale_test.db");
|
||||
let index = dir.path().join("stale_test.idx");
|
||||
|
||||
// Key 1 -> sort 30, Key 2 -> sort 10, Key 3 -> sort 20
|
||||
let mut tree = V3Tree::new();
|
||||
tree.insert(1u32, (30u32, String::from("one")));
|
||||
tree.insert(2u32, (10u32, String::from("two")));
|
||||
tree.insert(3u32, (20u32, String::from("three")));
|
||||
tree.store_with_index(&database, |v| v.0)?;
|
||||
|
||||
let snapshot = v4::IndexSnapshot::<u32, u32>::read(&index)?.expect("snapshot exists");
|
||||
|
||||
// Update database in place: modify key 2 and delete key 3, incrementing generation
|
||||
let mut updater = BPlusTreeUpdate::<u32, (u32, String)>::try_new(&database)?;
|
||||
updater.upsert(&2, &(10, String::from("two-updated")))?;
|
||||
updater.delete(&3)?;
|
||||
updater.commit()?;
|
||||
|
||||
// Restore index snapshot (written with stale generation)
|
||||
snapshot.write_merged(&index, &[])?;
|
||||
|
||||
// Index generation is now older than DB generation
|
||||
let query = V3Query::<u32, (u32, String)>::try_new(&database)?;
|
||||
let iterator = v4::OwnedIterator::<u32, (u32, String), u32>::open(query, &index)?;
|
||||
let items: Vec<(u32, (u32, String))> = iterator.collect::<io::Result<Vec<_>>>()?;
|
||||
|
||||
// Expected order: key 2 (sort 10), key 1 (sort 30); key 3 (deleted) is skipped
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0], (2, (10, String::from("two-updated"))));
|
||||
assert_eq!(items[1], (1, (30, String::from("one"))));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_replacement_of_existing_primary_key_maintains_canonical_order() -> io::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let index = dir.path().join("merge_test.idx");
|
||||
let database_id = [0x42; 16];
|
||||
let generation = 5;
|
||||
|
||||
let mut writer = v4::Writer::<u32, u32>::new(&index, database_id, generation)?;
|
||||
let dummy_loc = Locator { leaf_page_id: 1, slot_index: 0, serialized_key_crc32: 0 };
|
||||
writer.push(&10, &1, dummy_loc)?;
|
||||
writer.push(&30, &3, dummy_loc)?;
|
||||
writer.finish()?;
|
||||
|
||||
let snapshot = v4::IndexSnapshot::<u32, u32>::read(&index)?.expect("snapshot exists");
|
||||
snapshot.write_merged(&index, &[(5, 0), (50, 1)])?;
|
||||
|
||||
let mut reader = v4::Reader::<u32, u32>::open(&index, database_id, generation)?;
|
||||
let mut entries = Vec::new();
|
||||
while let Some(entry) = reader.read_next()? {
|
||||
entries.push((entry.sort_key, entry.primary_key));
|
||||
}
|
||||
|
||||
assert_eq!(entries, vec![(5, 0), (30, 3), (50, 1)]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_index_generation_rejected() -> io::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let database = dir.path().join("newer_gen.db");
|
||||
let index = dir.path().join("newer_gen.idx");
|
||||
|
||||
let mut tree = V3Tree::new();
|
||||
tree.insert(1u32, String::from("one"));
|
||||
tree.store(&database)?;
|
||||
|
||||
let query = V3Query::<u32, String>::try_new(&database)?;
|
||||
let (database_id, generation) = query.snapshot_identity();
|
||||
drop(query);
|
||||
|
||||
let mut writer = v4::Writer::<u32, u32>::new(&index, database_id, generation + 1)?;
|
||||
writer.push(&1, &1, Locator { leaf_page_id: 1, slot_index: 0, serialized_key_crc32: 0 })?;
|
||||
writer.finish()?;
|
||||
|
||||
let query = V3Query::<u32, String>::try_new(&database)?;
|
||||
let Err(err) = v4::OwnedIterator::<u32, String, u32>::open(query, &index) else {
|
||||
panic!("expected newer generation error");
|
||||
};
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains("newer than tree generation"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_id_mismatch_rejected() -> io::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let database = dir.path().join("mismatch.db");
|
||||
let index = dir.path().join("mismatch.idx");
|
||||
|
||||
let mut tree = V3Tree::new();
|
||||
tree.insert(1u32, String::from("one"));
|
||||
tree.store(&database)?;
|
||||
|
||||
let mut writer = v4::Writer::<u32, u32>::new(&index, [0xaa; 16], 1)?;
|
||||
writer.push(&1, &1, Locator { leaf_page_id: 1, slot_index: 0, serialized_key_crc32: 0 })?;
|
||||
writer.finish()?;
|
||||
|
||||
let query = V3Query::<u32, String>::try_new(&database)?;
|
||||
assert!(v4::OwnedIterator::<u32, String, u32>::open(query, &index).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -464,4 +719,21 @@ mod tests {
|
||||
assert!(iterator.next().is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_header_count_rejected() -> io::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let path = dir.path().join("oversized.idx");
|
||||
let database_id = [0x55; 16];
|
||||
let generation = 1;
|
||||
let encoded = v4::header(database_id, generation, 100);
|
||||
fs::write(&path, encoded)?;
|
||||
|
||||
let Err(err) = v4::IndexSnapshot::<u32, u32>::read(&path) else {
|
||||
panic!("oversized count must fail");
|
||||
};
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains("sorted-index count"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ mod wal;
|
||||
|
||||
pub use format::{Locator, MAGIC, STORAGE_VERSION_V3 as STORAGE_VERSION};
|
||||
pub use publish::{publish_staged_database, BPlusTreeStagingArtifacts};
|
||||
pub(crate) use tree::{publish_database, temporary_path};
|
||||
#[allow(unused_imports)]
|
||||
pub use tree::{
|
||||
BPlusTree, BPlusTreeDiskIterator, BPlusTreeDiskIteratorOwned, BPlusTreeKeyIterator, BPlusTreeQuery,
|
||||
BPlusTreeRangeIterator, BPlusTreeSerialWriter, BPlusTreeUpdate, FlushPolicy,
|
||||
};
|
||||
pub(crate) use wal::sync_parent_directory;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum BPlusTreeMetadata {
|
||||
|
||||
@@ -37,6 +37,7 @@ pub(crate) struct InternalSplit<'a, T> {
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static INTERNAL_KEY_DECODE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
static VERIFY_FULL_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -126,6 +127,7 @@ mod query;
|
||||
mod serial_writer;
|
||||
mod transaction;
|
||||
mod update;
|
||||
mod verified_cache;
|
||||
mod verify;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -135,13 +137,17 @@ pub(crate) use self::mutation::{choose_internal_split, choose_leaf_split, used_l
|
||||
#[cfg(test)]
|
||||
use self::query::{internal_key_decode_count, reset_internal_key_decode_count};
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::query::{read_leaf_value, validate_locator};
|
||||
#[cfg(test)]
|
||||
use self::transaction::page_byte_range;
|
||||
#[cfg(test)]
|
||||
pub(super) use self::verified_cache::{is_verified_with_cache, mark_verified_with_cache};
|
||||
pub(super) use self::{
|
||||
bulk_store::publish_database_and_invalidate_sorted_index,
|
||||
error::PublishDatabaseError,
|
||||
verified_cache::{is_verified, mark_verified, VerifiedIdentity},
|
||||
};
|
||||
use self::{
|
||||
bulk_store::{
|
||||
build_pages, internal_page, leaf_page, temporary_path, verify_internal_page, verify_leaf_page, PageSink,
|
||||
StoredDatabase,
|
||||
build_pages, internal_page, leaf_page, verify_internal_page, verify_leaf_page, PageSink, StoredDatabase,
|
||||
},
|
||||
error::{invalid_data, invalid_input},
|
||||
iterators::{record_page_visit, ChainGuard, CursorState},
|
||||
@@ -154,8 +160,15 @@ use self::{
|
||||
update::ActiveBatch,
|
||||
verify::{validate_transaction_links, VerifyLeaf, VerifyValue, Visit},
|
||||
};
|
||||
pub(super) use self::{
|
||||
bulk_store::{publish_database, publish_database_and_invalidate_sorted_index},
|
||||
error::PublishDatabaseError,
|
||||
pub(crate) use self::{
|
||||
bulk_store::{publish_database, temporary_path},
|
||||
query::search_leaf,
|
||||
verify::verify_full,
|
||||
};
|
||||
pub(crate) use self::{query::search_leaf, verify::verify_full};
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::{
|
||||
query::{read_leaf_value, validate_locator},
|
||||
verify::{reset_verify_full_count, verify_full_count},
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use super::wal::leave_committed_test_wal;
|
||||
|
||||
@@ -289,12 +289,12 @@ where
|
||||
Ok(level.first().ok_or_else(|| invalid_input("tree has no root page"))?.page_id)
|
||||
}
|
||||
|
||||
pub(super) fn temporary_path(filepath: &Path) -> io::Result<PathBuf> {
|
||||
pub(crate) fn temporary_path(filepath: &Path) -> io::Result<PathBuf> {
|
||||
let name = filepath.file_name().ok_or_else(|| invalid_input("database path has no file name"))?.to_string_lossy();
|
||||
Ok(filepath.with_file_name(format!("{name}.{}.v3.tmp", uuid::Uuid::new_v4())))
|
||||
}
|
||||
|
||||
pub(in crate::v3) fn publish_database(
|
||||
pub(crate) fn publish_database(
|
||||
temporary: &Path,
|
||||
destination: &Path,
|
||||
sync_directory: impl FnOnce(&Path) -> io::Result<()>,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#[derive(Debug)]
|
||||
pub(in crate::v3) enum PublishDatabaseError {
|
||||
pub(crate) enum PublishDatabaseError {
|
||||
NotPublished(io::Error),
|
||||
PublishedDurabilityUnknown(io::Error),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{
|
||||
build_pages, invalid_data, invalid_input, publish_database, publish_database_and_invalidate_sorted_index,
|
||||
build_pages, invalid_data, invalid_input, mark_verified, publish_database_and_invalidate_sorted_index,
|
||||
recover_pending_under_existing_lock, sync_parent_directory, temporary_path, verify_full, with_exclusive_sidecar,
|
||||
BPlusTree, BPlusTreeMetadata, BPlusTreeQuery, DatabaseHeader, PageSink, StoredDatabase, VerificationReport,
|
||||
VerifiedIdentity,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -103,10 +104,11 @@ where
|
||||
drop(sink);
|
||||
let mut query = BPlusTreeQuery::<K, V>::from_file_unlocked(File::open(&temp_path)?)?;
|
||||
let verification = verify_full(&mut query)?;
|
||||
let identity = VerifiedIdentity::from_header(&query.header);
|
||||
drop(query);
|
||||
Ok((root_page_id, verification))
|
||||
Ok((root_page_id, verification, identity))
|
||||
})();
|
||||
let (root_page_id, verification) = match prepared {
|
||||
let (root_page_id, verification, identity) = match prepared {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
@@ -114,6 +116,7 @@ where
|
||||
}
|
||||
};
|
||||
publish_database_and_invalidate_sorted_index(&temp_path, filepath, sync_published_directory)?;
|
||||
mark_verified(filepath, identity);
|
||||
self.dirty = false;
|
||||
Ok(StoredDatabase { root_page_id, verification })
|
||||
}
|
||||
@@ -186,20 +189,7 @@ where
|
||||
drop(query);
|
||||
|
||||
let index_path = crate::common::get_file_path_for_db_index(filepath);
|
||||
let temporary = temporary_path(&index_path)?;
|
||||
let prepared = (|| {
|
||||
let mut writer = crate::sorted_index::v4::Writer::new(&temporary, database_id, generation)?;
|
||||
for (sort_key, primary_key, locator) in &entries {
|
||||
writer.push(sort_key, primary_key, *locator)?;
|
||||
}
|
||||
let _ = writer.finish()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = prepared {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
publish_database(&temporary, &index_path, sync_parent_directory).map_err(io::Error::from)
|
||||
crate::sorted_index::publish_index(&index_path, database_id, generation, &entries)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -698,7 +698,7 @@ where
|
||||
K: Ord + for<'de> Deserialize<'de>,
|
||||
V: for<'de> Deserialize<'de>,
|
||||
{
|
||||
pub(super) fn query_io(&mut self, key: &K) -> io::Result<Option<V>> {
|
||||
pub(crate) fn query_io(&mut self, key: &K) -> io::Result<Option<V>> {
|
||||
let Some((leaf_id, range)) = self.locate_cell(key)? else { return Ok(None) };
|
||||
self.decode_entry_range(leaf_id, range).map(|entry| entry.map(|(_, value)| value))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod persistence;
|
||||
mod protocol;
|
||||
mod query;
|
||||
mod support;
|
||||
mod verified_cache;
|
||||
|
||||
use self::support::{
|
||||
assert_writer_is_blocked, database_header, empty_leaf, finish_writer, invalid_data, invalid_input, random_value,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
use super::super::{
|
||||
is_verified_with_cache, leave_committed_test_wal, mark_verified_with_cache, reset_verify_full_count,
|
||||
verify_full_count, BPlusTree, BPlusTreeUpdate, DatabaseHeader, VerifiedIdentity, PAGE_SIZE,
|
||||
};
|
||||
use crate::common::{read_exact_at_offset, write_all_at_offset};
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn second_try_new_on_unchanged_file_skips_verify_full() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("unchanged.db");
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.store(&path)?;
|
||||
|
||||
reset_verify_full_count();
|
||||
let updater1 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 0);
|
||||
drop(updater1);
|
||||
|
||||
let updater2 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 0);
|
||||
drop(updater2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncached_file_runs_verify_full_once_and_subsequent_skips() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let seed_path = dir.path().join("seed.db");
|
||||
let target_path = dir.path().join("target_uncached.db");
|
||||
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.store(&seed_path)?;
|
||||
|
||||
// Copying the file produces an uncached database at target_path
|
||||
fs::copy(&seed_path, &target_path)?;
|
||||
|
||||
reset_verify_full_count();
|
||||
|
||||
let updater1 = BPlusTreeUpdate::<u32, u64>::try_new(&target_path)?;
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
drop(updater1);
|
||||
|
||||
let updater2 = BPlusTreeUpdate::<u32, u64>::try_new(&target_path)?;
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
drop(updater2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_instance_after_commit_skips_verify_full() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("after_commit.db");
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.store(&path)?;
|
||||
|
||||
let mut updater1 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
updater1.upsert(&2, &200)?;
|
||||
updater1.commit()?;
|
||||
drop(updater1);
|
||||
|
||||
reset_verify_full_count();
|
||||
let updater2 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 0);
|
||||
drop(updater2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_by_store_runs_verify_full_once() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("replacement_store.db");
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.store(&path)?;
|
||||
|
||||
let updater1 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
drop(updater1);
|
||||
|
||||
reset_verify_full_count();
|
||||
let mut replacement = BPlusTree::new();
|
||||
replacement.insert(2u32, 200u64);
|
||||
replacement.store(&path)?;
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
|
||||
let updater2 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
drop(updater2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_write_changing_generation_triggers_verify_full() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("external_write.db");
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.store(&path)?;
|
||||
|
||||
let updater1 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
drop(updater1);
|
||||
|
||||
let file = OpenOptions::new().read(true).write(true).open(&path)?;
|
||||
let mut header_bytes = [0u8; PAGE_SIZE];
|
||||
read_exact_at_offset(&file, &mut header_bytes, 0)?;
|
||||
let mut header = DatabaseHeader::decode(&header_bytes)?;
|
||||
header.generation = header.generation.checked_add(1).ok_or_else(|| io::Error::other("generation overflow"))?;
|
||||
let encoded = header.encode()?;
|
||||
write_all_at_offset(&file, &encoded, 0)?;
|
||||
drop(file);
|
||||
|
||||
reset_verify_full_count();
|
||||
let updater2 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
drop(updater2);
|
||||
|
||||
let updater3 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
drop(updater3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_recovery_advancing_generation_triggers_verify_full() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("wal_recovery.db");
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.store(&path)?;
|
||||
|
||||
let updater1 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
drop(updater1);
|
||||
|
||||
leave_committed_test_wal(&path)?;
|
||||
|
||||
reset_verify_full_count();
|
||||
let updater2 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
drop(updater2);
|
||||
|
||||
let updater3 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
drop(updater3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_database_with_unverified_identity_is_rejected() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let seed_path = dir.path().join("seed_corrupt.db");
|
||||
let corrupt_path = dir.path().join("target_corrupt.db");
|
||||
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.insert(2u32, 200u64);
|
||||
tree.store(&seed_path)?;
|
||||
|
||||
// Copying creates an uncached database at corrupt_path
|
||||
fs::copy(&seed_path, &corrupt_path)?;
|
||||
|
||||
// Corrupt root page bytes (page 1)
|
||||
let file = OpenOptions::new().read(true).write(true).open(&corrupt_path)?;
|
||||
let garbage = [0xff; 128];
|
||||
write_all_at_offset(&file, &garbage, PAGE_SIZE as u64)?;
|
||||
drop(file);
|
||||
|
||||
reset_verify_full_count();
|
||||
let result = BPlusTreeUpdate::<u32, u64>::try_new(&corrupt_path);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(verify_full_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_marks_resulting_identity_and_subsequent_try_new_skips() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("compact.db");
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.store(&path)?;
|
||||
|
||||
let mut updater = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
updater.upsert(&2, &200)?;
|
||||
updater.compact()?;
|
||||
drop(updater);
|
||||
|
||||
reset_verify_full_count();
|
||||
let updater2 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
assert_eq!(verify_full_count(), 0);
|
||||
drop(updater2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_transaction_skips_when_committed_by_another_instance() -> io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("mismatch_ensure.db");
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, 100u64);
|
||||
tree.store(&path)?;
|
||||
|
||||
let mut updater1 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
let mut updater2 = BPlusTreeUpdate::<u32, u64>::try_new(&path)?;
|
||||
|
||||
updater1.upsert(&2, &200)?;
|
||||
updater1.commit()?;
|
||||
|
||||
reset_verify_full_count();
|
||||
updater2.upsert(&3, &300)?;
|
||||
assert_eq!(verify_full_count(), 0);
|
||||
updater2.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_mutex_treats_as_not_verified_and_never_panics() {
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
|
||||
let cache = Mutex::new(HashMap::new());
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _guard = cache.lock().unwrap();
|
||||
panic!("test panic to poison cache mutex");
|
||||
});
|
||||
assert!(cache.is_poisoned());
|
||||
|
||||
let identity = VerifiedIdentity::new([0; 16], 1, 1);
|
||||
let path = std::path::Path::new("/test");
|
||||
|
||||
// Poisoned mutex must be treated as not verified
|
||||
assert!(!is_verified_with_cache(&cache, path, identity));
|
||||
|
||||
// Marking on poisoned mutex must never panic and do nothing
|
||||
mark_verified_with_cache(&cache, path, identity);
|
||||
assert!(!is_verified_with_cache(&cache, path, identity));
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::{
|
||||
commit_ordered_page_refs_under_existing_lock, encode_value, invalid_data, invalid_input, query_transaction,
|
||||
recover_pending_under_existing_lock, stage_delete, stage_upsert, validate_transaction_links, verify_full,
|
||||
with_exclusive_sidecar, BPlusTree, BPlusTreeMetadata, BPlusTreeQuery, BPlusTreeUpdate, DatabaseHeader,
|
||||
DatabaseImage, ExclusiveSidecarGuard, FlushPolicy, WalOperationError, WalOutcome, WriteScratch, WriteTransaction,
|
||||
PAGE_SIZE,
|
||||
commit_ordered_page_refs_under_existing_lock, encode_value, invalid_data, invalid_input, is_verified,
|
||||
mark_verified, query_transaction, recover_pending_under_existing_lock, stage_delete, stage_upsert,
|
||||
validate_transaction_links, verify_full, with_exclusive_sidecar, BPlusTree, BPlusTreeMetadata, BPlusTreeQuery,
|
||||
BPlusTreeUpdate, DatabaseHeader, DatabaseImage, ExclusiveSidecarGuard, FlushPolicy, VerifiedIdentity,
|
||||
WalOperationError, WalOutcome, WriteScratch, WriteTransaction, PAGE_SIZE,
|
||||
};
|
||||
use crate::{
|
||||
codec::{binary_serialize, binary_serialize_into},
|
||||
@@ -18,6 +18,18 @@ pub(super) struct ActiveBatch {
|
||||
pub(super) _guard: ExclusiveSidecarGuard,
|
||||
}
|
||||
|
||||
fn verify_if_needed<K, V>(filepath: &Path, query: &mut BPlusTreeQuery<K, V>) -> io::Result<VerifiedIdentity>
|
||||
where
|
||||
K: Ord + for<'de> Deserialize<'de> + Clone,
|
||||
{
|
||||
let identity = VerifiedIdentity::from_header(&query.header);
|
||||
if !is_verified(filepath, identity) {
|
||||
let _ = verify_full(query)?;
|
||||
mark_verified(filepath, identity);
|
||||
}
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
impl<K, V> BPlusTreeUpdate<K, V>
|
||||
where
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone,
|
||||
@@ -25,16 +37,13 @@ where
|
||||
{
|
||||
pub fn try_new(filepath: &Path) -> io::Result<Self> {
|
||||
let mut query = BPlusTreeQuery::<K, V>::try_new(filepath)?;
|
||||
let _ = verify_full(&mut query)?;
|
||||
let database_id = query.header.database_id;
|
||||
let verified_generation = query.header.generation;
|
||||
let verified_next_page_id = query.header.next_page_id;
|
||||
let identity = verify_if_needed(filepath, &mut query)?;
|
||||
drop(query);
|
||||
Ok(Self {
|
||||
filepath: filepath.to_path_buf(),
|
||||
database_id,
|
||||
verified_generation,
|
||||
verified_next_page_id,
|
||||
database_id: identity.database_id,
|
||||
verified_generation: identity.generation,
|
||||
verified_next_page_id: identity.next_page_id,
|
||||
flush_policy: FlushPolicy::Immediate,
|
||||
active: None,
|
||||
scratch: WriteScratch::default(),
|
||||
@@ -62,10 +71,10 @@ where
|
||||
|| header.next_page_id != self.verified_next_page_id
|
||||
{
|
||||
let mut query = BPlusTreeQuery::<K, V>::from_file_unlocked(File::open(&self.filepath)?)?;
|
||||
let _ = verify_full(&mut query)?;
|
||||
self.database_id = query.header.database_id;
|
||||
self.verified_generation = query.header.generation;
|
||||
self.verified_next_page_id = query.header.next_page_id;
|
||||
let identity = verify_if_needed(&self.filepath, &mut query)?;
|
||||
self.database_id = identity.database_id;
|
||||
self.verified_generation = identity.generation;
|
||||
self.verified_next_page_id = identity.next_page_id;
|
||||
}
|
||||
let transaction = WriteTransaction::new(header, base.as_slice().len())?;
|
||||
self.active = Some(ActiveBatch { base, transaction, _guard: guard });
|
||||
@@ -339,11 +348,13 @@ where
|
||||
let committed_header = DatabaseHeader::decode(prepared[0].1)?;
|
||||
let committed_generation = committed_header.generation;
|
||||
let committed_next_page_id = committed_header.next_page_id;
|
||||
let committed_identity = VerifiedIdentity::from_header(&committed_header);
|
||||
let result = commit_ordered_page_refs_under_existing_lock(&self.filepath, &prepared);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.verified_generation = committed_generation;
|
||||
self.verified_next_page_id = committed_next_page_id;
|
||||
mark_verified(&self.filepath, committed_identity);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -354,6 +365,7 @@ where
|
||||
if outcome == Some(WalOutcome::CommittedCleanupPending) {
|
||||
self.verified_generation = committed_generation;
|
||||
self.verified_next_page_id = committed_next_page_id;
|
||||
mark_verified(&self.filepath, committed_identity);
|
||||
}
|
||||
if let Err(recovery_error) = recover_pending_under_existing_lock(&self.filepath) {
|
||||
log::error!(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::DatabaseHeader;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{LazyLock, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct VerifiedIdentity {
|
||||
pub(crate) database_id: [u8; 16],
|
||||
pub(crate) generation: u64,
|
||||
pub(crate) next_page_id: u64,
|
||||
}
|
||||
|
||||
impl VerifiedIdentity {
|
||||
#[cfg(test)]
|
||||
pub(crate) const fn new(database_id: [u8; 16], generation: u64, next_page_id: u64) -> Self {
|
||||
Self { database_id, generation, next_page_id }
|
||||
}
|
||||
|
||||
pub(crate) const fn from_header(header: &DatabaseHeader) -> Self {
|
||||
Self { database_id: header.database_id, generation: header.generation, next_page_id: header.next_page_id }
|
||||
}
|
||||
}
|
||||
|
||||
static VERIFIED_IDENTITIES: LazyLock<Mutex<HashMap<PathBuf, VerifiedIdentity>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn canonical_path(path: &Path) -> PathBuf {
|
||||
match std::fs::canonicalize(path) {
|
||||
Ok(canonical) => canonical,
|
||||
Err(_) => path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_verified_with_cache(
|
||||
cache: &Mutex<HashMap<PathBuf, VerifiedIdentity>>,
|
||||
path: &Path,
|
||||
identity: VerifiedIdentity,
|
||||
) -> bool {
|
||||
let canonical = canonical_path(path);
|
||||
let Ok(guard) = cache.lock() else {
|
||||
return false;
|
||||
};
|
||||
guard.get(&canonical).copied() == Some(identity)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_verified_with_cache(
|
||||
cache: &Mutex<HashMap<PathBuf, VerifiedIdentity>>,
|
||||
path: &Path,
|
||||
identity: VerifiedIdentity,
|
||||
) {
|
||||
let canonical = canonical_path(path);
|
||||
let Ok(mut guard) = cache.lock() else {
|
||||
return;
|
||||
};
|
||||
let _ = guard.insert(canonical, identity);
|
||||
}
|
||||
|
||||
pub(crate) fn is_verified(path: &Path, identity: VerifiedIdentity) -> bool {
|
||||
is_verified_with_cache(&VERIFIED_IDENTITIES, path, identity)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_verified(path: &Path, identity: VerifiedIdentity) {
|
||||
mark_verified_with_cache(&VERIFIED_IDENTITIES, path, identity);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#[cfg(test)]
|
||||
use super::VERIFY_FULL_COUNT;
|
||||
use super::{
|
||||
decode_key, invalid_data, minimum_tree_key, push_child_visits, record_page_visit, validate_leaf_backlink,
|
||||
validated_overflow_chain_pages, verify_internal_page, verify_leaf_page, BPlusTreeQuery, Compression,
|
||||
@@ -233,10 +235,19 @@ fn verify_page_ownership(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_verify_full_count() { VERIFY_FULL_COUNT.set(0); }
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn verify_full_count() -> usize { VERIFY_FULL_COUNT.get() }
|
||||
|
||||
pub(crate) fn verify_full<K, V>(query: &mut BPlusTreeQuery<K, V>) -> io::Result<VerificationReport>
|
||||
where
|
||||
K: Ord + for<'de> Deserialize<'de> + Clone,
|
||||
{
|
||||
#[cfg(test)]
|
||||
VERIFY_FULL_COUNT.set(VERIFY_FULL_COUNT.get().saturating_add(1));
|
||||
|
||||
let mut tree_pages = HashSet::new();
|
||||
let mut active = HashSet::new();
|
||||
let mut overflow_pages = HashSet::new();
|
||||
|
||||
@@ -547,14 +547,14 @@ fn new_transaction_id() -> io::Result<u64> {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn sync_parent_directory(path: &Path) -> io::Result<()> {
|
||||
pub(crate) fn sync_parent_directory(path: &Path) -> io::Result<()> {
|
||||
let parent = path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
|
||||
File::open(parent)?.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub(super) fn sync_parent_directory(_path: &Path) -> io::Result<()> { Ok(()) }
|
||||
pub(crate) fn sync_parent_directory(_path: &Path) -> io::Result<()> { Ok(()) }
|
||||
|
||||
pub(crate) fn invalidate_sorted_index(database: &Path) -> io::Result<()> {
|
||||
let index = crate::common::get_file_path_for_db_index(database);
|
||||
@@ -763,6 +763,40 @@ pub(super) fn leave_uncommitted_test_wal_after_database_write(database: &Path) -
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn leave_committed_test_wal(database: &Path) -> io::Result<()> {
|
||||
let mut header_page = [0u8; PAGE_SIZE];
|
||||
File::open(database)?.read_exact(&mut header_page)?;
|
||||
let mut updated_header = DatabaseHeader::decode(&header_page)?;
|
||||
updated_header.generation = updated_header
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| io::Error::other("test database generation overflow"))?;
|
||||
let prepared = [(0, updated_header.encode()?)];
|
||||
let commit_result = commit_prepared_pages_with_hook(database, &prepared, |boundary| {
|
||||
if boundary == CommitBoundary::CommitSynced {
|
||||
Err(io::Error::other("test fault after commit sync"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
match commit_result {
|
||||
Err(error) if !error.to_string().contains("test fault after commit sync") => Err(io::Error::new(
|
||||
error.kind(),
|
||||
format!("test commit for {} failed before the requested boundary: {error}", database.display()),
|
||||
)),
|
||||
Err(error) if !wal_path(database).try_exists()? => Err(io::Error::new(
|
||||
error.kind(),
|
||||
format!(
|
||||
"test commit for {} reached the requested boundary without leaving an active WAL: {error}",
|
||||
database.display()
|
||||
),
|
||||
)),
|
||||
Err(_) => Ok(()),
|
||||
Ok(()) => Err(io::Error::other(format!("test commit for {} unexpectedly completed", database.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn commit_prepared_pages_with_hook_under_existing_lock(
|
||||
database_path: &Path,
|
||||
|
||||
@@ -76,6 +76,7 @@ const RESOURCE_DESTINATION_LOOKUP_TIMEOUT: Duration = Duration::from_millis(250)
|
||||
pub struct RequestFetchOptions {
|
||||
stop_on_proxy_authentication_required: bool,
|
||||
pub attempt_idle_timeout: Option<Duration>,
|
||||
header_timeout_only: bool,
|
||||
content_coding: OutboundContentCodingPolicy,
|
||||
resource_retry: ResourceRetryExecution,
|
||||
return_http_errors: bool,
|
||||
|
||||
@@ -55,6 +55,11 @@ impl RequestFetchOptions {
|
||||
Self { attempt_idle_timeout: Some(timeout.max(Duration::from_millis(1))), ..Self::default() }
|
||||
}
|
||||
|
||||
pub const fn with_header_timeout_only(mut self) -> Self {
|
||||
self.header_timeout_only = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_content_coding(mut self, content_coding: OutboundContentCodingPolicy) -> Self {
|
||||
self.content_coding = content_coding;
|
||||
self
|
||||
|
||||
@@ -9,8 +9,10 @@ pub(super) enum ResourceRetryExecution {
|
||||
}
|
||||
|
||||
pub(super) fn apply_request_fetch_options(request: &mut reqwest::Request, options: RequestFetchOptions) {
|
||||
if let Some(timeout) = options.attempt_idle_timeout {
|
||||
*request.timeout_mut() = Some(timeout);
|
||||
if !options.header_timeout_only {
|
||||
if let Some(timeout) = options.attempt_idle_timeout {
|
||||
*request.timeout_mut() = Some(timeout);
|
||||
}
|
||||
}
|
||||
apply_outbound_content_coding_policy(request.headers_mut(), options.content_coding);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ impl CurationMediaScope {
|
||||
fn includes_playlist_item(self, item_type: PlaylistItemType) -> bool {
|
||||
match self {
|
||||
Self::Movies => item_type.is_video(),
|
||||
Self::Series => matches!(item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo),
|
||||
Self::Series => item_type.is_series_info(),
|
||||
Self::Both => matches!(
|
||||
item_type,
|
||||
PlaylistItemType::Video
|
||||
@@ -479,8 +479,7 @@ fn create_category_from_items(
|
||||
for item in items {
|
||||
let projected_item = clone_item_for_category(item, specification, &group_title);
|
||||
let parent_uuid = projected_item.header.uuid.intern();
|
||||
let is_series_info =
|
||||
matches!(projected_item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo);
|
||||
let is_series_info = projected_item.header.item_type.is_series_info();
|
||||
let child_lookup_keys = if is_series_info { series_info_child_lookup_keys(item) } else { Vec::new() };
|
||||
let cluster = projected_item.header.xtream_cluster;
|
||||
matched_items_by_cluster.entry(cluster).or_default().push(projected_item);
|
||||
|
||||
@@ -134,12 +134,15 @@ pub async fn get_xtream_stream_info(
|
||||
vod_pli.additional_properties =
|
||||
Some(StreamProperties::Video(Box::new(video_stream_props)));
|
||||
|
||||
if let Err(err) = write_playlist_item_update(app_config, &target.name, &vod_pli).await {
|
||||
error!("Failed to persist video stream: {err}");
|
||||
}
|
||||
|
||||
if target.use_memory_cache {
|
||||
playlists.update_playlist_items(target, vec![&vod_pli]).await;
|
||||
match write_playlist_item_update(app_config, &target.name, &vod_pli).await {
|
||||
Ok(()) => {
|
||||
if target.use_memory_cache {
|
||||
playlists.update_playlist_items(target, &[&vod_pli]).await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to persist video stream: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = xtream_resolve_stream_info(
|
||||
@@ -258,7 +261,7 @@ pub async fn get_xtream_stream_info(
|
||||
|
||||
let xtream_episodes: Vec<XtreamPlaylistItem> =
|
||||
episodes.iter().map(XtreamPlaylistItem::from).collect();
|
||||
if let Err(err) = write_playlist_batch_item_upsert(
|
||||
match write_playlist_batch_item_upsert(
|
||||
app_config,
|
||||
&target.name,
|
||||
XtreamCluster::Series,
|
||||
@@ -266,12 +269,15 @@ pub async fn get_xtream_stream_info(
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to persist playlist batch item update: {err}");
|
||||
}
|
||||
|
||||
if target.use_memory_cache && !in_memory_updates.is_empty() {
|
||||
playlists.insert_playlist_items(target, episodes).await;
|
||||
playlists.update_target_id_mapping(target, in_memory_updates).await;
|
||||
Ok(()) => {
|
||||
if target.use_memory_cache && !in_memory_updates.is_empty() {
|
||||
playlists.insert_playlist_items(target, episodes).await;
|
||||
playlists.update_target_id_mapping(target, in_memory_updates).await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to persist playlist batch item update: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
if !provider_series.is_empty() {
|
||||
@@ -282,12 +288,18 @@ pub async fn get_xtream_stream_info(
|
||||
&mut series_pli,
|
||||
&provider_series,
|
||||
);
|
||||
if let Err(err) =
|
||||
write_playlist_item_update(app_config, &target.name, &series_pli).await
|
||||
match write_playlist_item_update(app_config, &target.name, &series_pli)
|
||||
.await
|
||||
{
|
||||
error!("Failed to persist series stream: {err}");
|
||||
Ok(()) => {
|
||||
if target.use_memory_cache {
|
||||
playlists.update_playlist_items(target, &[&series_pli]).await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to persist series stream: {err}");
|
||||
}
|
||||
}
|
||||
playlists.update_playlist_items(target, vec![&series_pli]).await;
|
||||
|
||||
if let Some(value) = xtream_resolve_stream_info(
|
||||
app_config,
|
||||
|
||||
@@ -192,7 +192,7 @@ macro_rules! apply_cascade_updates {
|
||||
}
|
||||
|
||||
if target.use_memory_cache {
|
||||
Self::update_memory_cache(ctx, target_name, $cluster, updates).await;
|
||||
Self::update_memory_cache(&ctx.playlists, target_name, $cluster, updates).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ use tuliprox_core::{
|
||||
};
|
||||
use tuliprox_repository::{
|
||||
get_input_storage_path, get_target_id_mapping_file, persist_input_live_info_batch, persist_input_series_info_batch,
|
||||
persist_input_vod_info_batch, xtream_get_file_path, BPlusTreeQuery, TargetIdMapping,
|
||||
persist_input_vod_info_batch, xtream_get_file_path, BPlusTreeQuery, PlaylistStorageState, TargetIdMapping,
|
||||
};
|
||||
|
||||
pub(super) struct DbHandle {
|
||||
@@ -238,22 +238,17 @@ impl InputWorker {
|
||||
resolved
|
||||
}
|
||||
|
||||
pub(super) async fn update_memory_cache<E: EventSink + Clone + 'static>(
|
||||
ctx: &MetadataUpdateCtx<E>,
|
||||
pub(super) async fn update_memory_cache(
|
||||
playlists: &PlaylistStorageState,
|
||||
target_name: &str,
|
||||
cluster: XtreamCluster,
|
||||
updates: Vec<XtreamPlaylistItem>,
|
||||
) {
|
||||
let mut playlists = ctx.playlists.data.write().await;
|
||||
let mut playlists = playlists.data.write().await;
|
||||
if let Some(playlist) = playlists.get_mut(target_name) {
|
||||
if let Some(xtream_storage) = &mut playlist.xtream {
|
||||
let storage = match cluster {
|
||||
XtreamCluster::Live => &mut xtream_storage.live,
|
||||
XtreamCluster::Video => &mut xtream_storage.vod,
|
||||
XtreamCluster::Series => &mut xtream_storage.series,
|
||||
};
|
||||
for item in updates {
|
||||
storage.insert(item.virtual_id.get(), item);
|
||||
xtream_storage.upsert(cluster, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,9 +1007,7 @@ fn item_subject_uuid(item: &PlaylistItem) -> UUIDType {
|
||||
fn selected_series_parent_keys(playlist: &[PlaylistGroup], selected_subjects: &HashSet<UUIDType>) -> HashSet<Arc<str>> {
|
||||
let mut parent_keys = HashSet::new();
|
||||
for item in playlist.iter().flat_map(|group| &group.channels) {
|
||||
if !matches!(item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|
||||
|| !selected_subjects.contains(&item_subject_uuid(item))
|
||||
{
|
||||
if !item.header.item_type.is_series_info() || !selected_subjects.contains(&item_subject_uuid(item)) {
|
||||
continue;
|
||||
}
|
||||
parent_keys.insert(item.get_uuid().intern());
|
||||
|
||||
@@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize};
|
||||
use shared::{
|
||||
concat_string,
|
||||
error::TuliproxError,
|
||||
model::{PlaylistGroup, PlaylistItemType, TargetType, XtreamCluster},
|
||||
model::{PlaylistGroup, TargetType, XtreamCluster},
|
||||
};
|
||||
use std::{
|
||||
io,
|
||||
@@ -176,9 +176,7 @@ fn playlist_rows(playlist: &[PlaylistGroup], view: LineupGuideView) -> Vec<Lineu
|
||||
let header = &item.header;
|
||||
let cluster = header.xtream_cluster;
|
||||
let indexed = match view {
|
||||
LineupGuideView::M3u => {
|
||||
!matches!(header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|
||||
}
|
||||
LineupGuideView::M3u => !header.item_type.is_series_info(),
|
||||
LineupGuideView::Xtream => matches!(cluster, XtreamCluster::Live | XtreamCluster::Video),
|
||||
};
|
||||
if !indexed {
|
||||
@@ -393,7 +391,7 @@ pub async fn lineup_guide_candidates(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::{
|
||||
model::{PlaylistItem, PlaylistItemHeader, VirtualId},
|
||||
model::{PlaylistItem, PlaylistItemHeader, PlaylistItemType, VirtualId},
|
||||
utils::Internable,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -15,10 +15,7 @@ use log::{error, warn};
|
||||
use shared::{
|
||||
concat_string,
|
||||
error::{str_to_io_error, string_to_io_error, TuliproxError},
|
||||
model::{
|
||||
LiveStreamProperties, M3uPlaylistItem, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties,
|
||||
XtreamCluster,
|
||||
},
|
||||
model::{LiveStreamProperties, M3uPlaylistItem, PlaylistGroup, PlaylistItem, StreamProperties, XtreamCluster},
|
||||
utils::{m3u_flussonic_live_file_index_key, m3u_stream_url_identity, PROVIDER_SCHEME_PREFIX},
|
||||
};
|
||||
use std::{
|
||||
@@ -277,9 +274,7 @@ pub async fn m3u_write_playlist(
|
||||
let m3u_playlist = new_playlist
|
||||
.iter()
|
||||
.flat_map(|pg| &pg.channels)
|
||||
.filter(|&pli| {
|
||||
!matches!(pli.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|
||||
})
|
||||
.filter(|&pli| !pli.header.item_type.is_series_info())
|
||||
.map(M3uPlaylistItem::from)
|
||||
.collect::<Vec<M3uPlaylistItem>>();
|
||||
|
||||
|
||||
@@ -1,13 +1,118 @@
|
||||
use super::{BPlusTree, VirtualIdRecord};
|
||||
use shared::model::{M3uPlaylistItem, PlaylistItem, VirtualId, XtreamCluster, XtreamPlaylistItem};
|
||||
use shared::model::{M3uPlaylistItem, PlaylistItem, PlaylistItemType, VirtualId, XtreamCluster, XtreamPlaylistItem};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
use tuliprox_core::model::ConfigTarget;
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_cluster_order_eligible(cluster: XtreamCluster, item_type: PlaylistItemType) -> bool {
|
||||
match cluster {
|
||||
XtreamCluster::Series => item_type.is_series_info(),
|
||||
XtreamCluster::Live | XtreamCluster::Video => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlaylistXtreamStorage {
|
||||
pub live: BPlusTree<u32, XtreamPlaylistItem>,
|
||||
pub live_order: Vec<(u32, u32)>,
|
||||
pub vod: BPlusTree<u32, XtreamPlaylistItem>,
|
||||
pub vod_order: Vec<(u32, u32)>,
|
||||
pub series: BPlusTree<u32, XtreamPlaylistItem>,
|
||||
pub series_order: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl Default for PlaylistXtreamStorage {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl PlaylistXtreamStorage {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
live: BPlusTree::new(),
|
||||
live_order: Vec::new(),
|
||||
vod: BPlusTree::new(),
|
||||
vod_order: Vec::new(),
|
||||
series: BPlusTree::new(),
|
||||
series_order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_trees(
|
||||
live: BPlusTree<u32, XtreamPlaylistItem>,
|
||||
vod: BPlusTree<u32, XtreamPlaylistItem>,
|
||||
series: BPlusTree<u32, XtreamPlaylistItem>,
|
||||
) -> Self {
|
||||
let live_order = Self::build_cluster_order(XtreamCluster::Live, &live);
|
||||
let vod_order = Self::build_cluster_order(XtreamCluster::Video, &vod);
|
||||
let series_order = Self::build_cluster_order(XtreamCluster::Series, &series);
|
||||
Self { live, live_order, vod, vod_order, series, series_order }
|
||||
}
|
||||
|
||||
pub(crate) fn build_cluster_order(
|
||||
cluster: XtreamCluster,
|
||||
tree: &BPlusTree<u32, XtreamPlaylistItem>,
|
||||
) -> Vec<(u32, u32)> {
|
||||
let mut order: Vec<(u32, u32)> = tree
|
||||
.iter()
|
||||
.filter(|(_, item)| is_cluster_order_eligible(cluster, item.item_type))
|
||||
.map(|(key, item)| (item.source_ordinal, *key))
|
||||
.collect();
|
||||
order.sort_unstable();
|
||||
order
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_cluster(&self, cluster: XtreamCluster) -> (&BPlusTree<u32, XtreamPlaylistItem>, &[(u32, u32)]) {
|
||||
match cluster {
|
||||
XtreamCluster::Live => (&self.live, &self.live_order),
|
||||
XtreamCluster::Video => (&self.vod, &self.vod_order),
|
||||
XtreamCluster::Series => (&self.series, &self.series_order),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_cluster_mut(
|
||||
&mut self,
|
||||
cluster: XtreamCluster,
|
||||
) -> (&mut BPlusTree<u32, XtreamPlaylistItem>, &mut Vec<(u32, u32)>) {
|
||||
match cluster {
|
||||
XtreamCluster::Live => (&mut self.live, &mut self.live_order),
|
||||
XtreamCluster::Video => (&mut self.vod, &mut self.vod_order),
|
||||
XtreamCluster::Series => (&mut self.series, &mut self.series_order),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts or replaces one item. New keys are binary-search inserted into the cluster order,
|
||||
/// and repositioned when `source_ordinal` changes. Series items are stored without season/episode details;
|
||||
/// lookups that need them read the item from disk. In the Series cluster only `SeriesInfo`/`LocalSeriesInfo`
|
||||
/// enter the order.
|
||||
pub fn upsert(&mut self, cluster: XtreamCluster, mut item: XtreamPlaylistItem) {
|
||||
if cluster == XtreamCluster::Series {
|
||||
item.clear_series_details();
|
||||
}
|
||||
let key = item.virtual_id.get();
|
||||
let new_ordinal = item.source_ordinal;
|
||||
let new_eligible = is_cluster_order_eligible(cluster, item.item_type);
|
||||
|
||||
let (tree, order) = self.get_cluster_mut(cluster);
|
||||
let mut already_in_place = false;
|
||||
if let Some(old) = tree.query(&key) {
|
||||
let old_ordinal = old.source_ordinal;
|
||||
let old_eligible = is_cluster_order_eligible(cluster, old.item_type);
|
||||
if old_eligible {
|
||||
if new_eligible && old_ordinal == new_ordinal {
|
||||
already_in_place = true;
|
||||
} else if let Ok(pos) = order.binary_search(&(old_ordinal, key)) {
|
||||
order.remove(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
if new_eligible && !already_in_place {
|
||||
let pos = order.binary_search(&(new_ordinal, key)).unwrap_or_else(|pos| pos);
|
||||
order.insert(pos, (new_ordinal, key));
|
||||
}
|
||||
tree.insert(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
pub type PlaylistM3uStorage = BPlusTree<u32, M3uPlaylistItem>;
|
||||
@@ -53,17 +158,12 @@ impl PlaylistStorageState {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_playlist_items(&self, target: &ConfigTarget, pli_list: Vec<&XtreamPlaylistItem>) {
|
||||
pub async fn update_playlist_items(&self, target: &ConfigTarget, pli_list: &[&XtreamPlaylistItem]) {
|
||||
if target.use_memory_cache {
|
||||
if let Some(storage) = self.data.write().await.get_mut(&target.name) {
|
||||
if let Some(xtream) = storage.xtream.as_mut() {
|
||||
for pli in pli_list {
|
||||
match pli.xtream_cluster {
|
||||
XtreamCluster::Live => &mut xtream.live,
|
||||
XtreamCluster::Video => &mut xtream.vod,
|
||||
XtreamCluster::Series => &mut xtream.series,
|
||||
}
|
||||
.insert(pli.virtual_id.get(), pli.clone());
|
||||
for &pli in pli_list {
|
||||
xtream.upsert(pli.xtream_cluster, pli.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,12 +175,7 @@ impl PlaylistStorageState {
|
||||
if let Some(storage) = self.data.write().await.get_mut(&target.name) {
|
||||
if let Some(xtream) = storage.xtream.as_mut() {
|
||||
for pli in pli_list {
|
||||
match pli.header.xtream_cluster {
|
||||
XtreamCluster::Live => &mut xtream.live,
|
||||
XtreamCluster::Video => &mut xtream.vod,
|
||||
XtreamCluster::Series => &mut xtream.series,
|
||||
}
|
||||
.insert(pli.header.virtual_id.get(), XtreamPlaylistItem::from(&pli));
|
||||
xtream.upsert(pli.header.xtream_cluster, XtreamPlaylistItem::from(&pli));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,3 +229,172 @@ impl PlaylistStorageState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::{
|
||||
model::{PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailProperties, StreamProperties, VirtualId},
|
||||
utils::Internable,
|
||||
};
|
||||
use tuliprox_core::model::{ConfigTarget, TargetOutput, XtreamTargetFlagsSet, XtreamTargetOutput};
|
||||
|
||||
fn sample_target(use_memory_cache: bool) -> ConfigTarget {
|
||||
let mut target = crate::test_config::target_with_output(
|
||||
"test-target",
|
||||
TargetOutput::Xtream(XtreamTargetOutput { flags: XtreamTargetFlagsSet::new(), trakt: None, filter: None }),
|
||||
);
|
||||
target.use_memory_cache = use_memory_cache;
|
||||
target
|
||||
}
|
||||
|
||||
fn sample_xtream_item(id: u32, cluster: XtreamCluster) -> XtreamPlaylistItem {
|
||||
XtreamPlaylistItem {
|
||||
virtual_id: VirtualId::new(id),
|
||||
provider_id: id,
|
||||
name: format!("Item {id}").intern(),
|
||||
logo: "".intern(),
|
||||
logo_small: "".intern(),
|
||||
group: "".intern(),
|
||||
title: "".intern(),
|
||||
parent_code: "".intern(),
|
||||
rec: "".intern(),
|
||||
url: "".intern(),
|
||||
epg_channel_id: None,
|
||||
xtream_cluster: cluster,
|
||||
additional_properties: None,
|
||||
item_type: match cluster {
|
||||
XtreamCluster::Live => PlaylistItemType::Live,
|
||||
XtreamCluster::Video => PlaylistItemType::Video,
|
||||
XtreamCluster::Series => PlaylistItemType::SeriesInfo,
|
||||
},
|
||||
category_id: 1,
|
||||
input_name: "test".intern(),
|
||||
channel_no: id,
|
||||
source_ordinal: id,
|
||||
input_stream_id: id.to_string().intern(),
|
||||
upstream_user_agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_playlist_items_appends_only_new_keys_to_order() {
|
||||
let playlists = PlaylistStorageState::new();
|
||||
let target = sample_target(true);
|
||||
|
||||
// Prepopulate storage with items 10 and 20
|
||||
let mut storage = PlaylistXtreamStorage::new();
|
||||
let item10 = sample_xtream_item(10, XtreamCluster::Live);
|
||||
let item20 = sample_xtream_item(20, XtreamCluster::Live);
|
||||
storage.live.insert(10, item10.clone());
|
||||
storage.live.insert(20, item20.clone());
|
||||
storage.live_order = vec![(10, 10), (20, 20)];
|
||||
|
||||
playlists.cache_playlist(&target.name, PlaylistStorage::XtreamPlaylist(Box::new(storage))).await;
|
||||
|
||||
// 1. Update existing key 20 with same source_ordinal: order must NOT duplicate 20
|
||||
playlists.update_playlist_items(&target, &[&item20]).await;
|
||||
{
|
||||
let data = playlists.data.read().await;
|
||||
let xtream = data.get(&target.name).unwrap().xtream.as_ref().unwrap();
|
||||
assert_eq!(xtream.live_order, vec![(10, 10), (20, 20)]);
|
||||
}
|
||||
|
||||
// 2. Add new key 30 via update_playlist_items: order must binary-search insert 30
|
||||
let item30 = sample_xtream_item(30, XtreamCluster::Live);
|
||||
playlists.update_playlist_items(&target, &[&item30]).await;
|
||||
{
|
||||
let data = playlists.data.read().await;
|
||||
let xtream = data.get(&target.name).unwrap().xtream.as_ref().unwrap();
|
||||
assert_eq!(xtream.live_order, vec![(10, 10), (20, 20), (30, 30)]);
|
||||
}
|
||||
|
||||
// 3. Update existing key 20 with changed source_ordinal 5: repositions before 10
|
||||
let mut item20_moved = item20.clone();
|
||||
item20_moved.source_ordinal = 5;
|
||||
playlists.update_playlist_items(&target, &[&item20_moved]).await;
|
||||
{
|
||||
let data = playlists.data.read().await;
|
||||
let xtream = data.get(&target.name).unwrap().xtream.as_ref().unwrap();
|
||||
assert_eq!(xtream.live_order, vec![(5, 20), (10, 10), (30, 30)]);
|
||||
}
|
||||
|
||||
// 4. Add existing key 10 via insert_playlist_items: order unchanged
|
||||
let pli10 = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
virtual_id: VirtualId::new(10),
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
source_ordinal: 10,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
playlists.insert_playlist_items(&target, vec![pli10]).await;
|
||||
{
|
||||
let data = playlists.data.read().await;
|
||||
let xtream = data.get(&target.name).unwrap().xtream.as_ref().unwrap();
|
||||
assert_eq!(xtream.live_order, vec![(5, 20), (10, 10), (30, 30)]);
|
||||
}
|
||||
|
||||
// 5. Add new key 40 via insert_playlist_items: order must binary-search insert 40
|
||||
let pli40 = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
virtual_id: VirtualId::new(40),
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
source_ordinal: 40,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
playlists.insert_playlist_items(&target, vec![pli40]).await;
|
||||
{
|
||||
let data = playlists.data.read().await;
|
||||
let xtream = data.get(&target.name).unwrap().xtream.as_ref().unwrap();
|
||||
assert_eq!(xtream.live_order, vec![(5, 20), (10, 10), (30, 30), (40, 40)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn series_episodes_do_not_enter_series_order() {
|
||||
let playlists = PlaylistStorageState::new();
|
||||
let target = sample_target(true);
|
||||
|
||||
playlists.cache_playlist(&target.name, PlaylistStorage::XtreamPlaylist(Box::default())).await;
|
||||
|
||||
// SeriesInfo enters the order
|
||||
let series_item = crate::test_config::xtream_series_item(100, None);
|
||||
playlists.update_playlist_items(&target, &[&series_item]).await;
|
||||
|
||||
// Series episode does not enter the order
|
||||
let mut episode_item = sample_xtream_item(200, XtreamCluster::Series);
|
||||
episode_item.item_type = PlaylistItemType::Series;
|
||||
playlists.update_playlist_items(&target, &[&episode_item]).await;
|
||||
|
||||
let data = playlists.data.read().await;
|
||||
let xtream = data.get(&target.name).unwrap().xtream.as_ref().unwrap();
|
||||
assert_eq!(xtream.series_order, vec![(0, 100)]);
|
||||
assert!(xtream.series.query(&200).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_playlist_items_strips_series_details_in_cache() {
|
||||
let playlists = PlaylistStorageState::new();
|
||||
let target = sample_target(true);
|
||||
|
||||
playlists.cache_playlist(&target.name, PlaylistStorage::XtreamPlaylist(Box::default())).await;
|
||||
|
||||
let details = SeriesStreamDetailProperties { year: Some(2024), seasons: None, episodes: None };
|
||||
let series_item = crate::test_config::xtream_series_item(77, Some(details));
|
||||
assert!(series_item.has_details());
|
||||
|
||||
// Update with series item carrying details
|
||||
playlists.update_playlist_items(&target, &[&series_item]).await;
|
||||
|
||||
let data = playlists.data.read().await;
|
||||
let xtream = data.get(&target.name).unwrap().xtream.as_ref().unwrap();
|
||||
let cached = xtream.series.query(&77).expect("series item present in cache");
|
||||
assert!(!cached.has_details(), "cached series item must not have details");
|
||||
match &cached.additional_properties {
|
||||
Some(StreamProperties::Series(props)) => assert_eq!(props.details, None),
|
||||
other => panic!("unexpected properties: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
use super::publication::TargetCacheReloadStage;
|
||||
use super::{publication::TargetPersistenceMode, PlaylistM3uStorage, PlaylistXtreamStorage, TargetPlaylistStorage};
|
||||
use crate::{
|
||||
get_target_id_mapping_file, get_target_storage_path, m3u_get_file_path_for_db, xtream_get_file_path,
|
||||
xtream_get_storage_path, BPlusTree, TargetIdMapping, VirtualIdRecord,
|
||||
get_file_path_for_db_index, get_target_id_mapping_file, get_target_storage_path, m3u_get_file_path_for_db,
|
||||
xtream_get_file_path, xtream_get_storage_path, BPlusTree, TargetIdMapping, VirtualIdRecord,
|
||||
};
|
||||
use log::{debug, warn};
|
||||
use shared::{
|
||||
@@ -49,26 +49,43 @@ async fn load_target_id_mapping_as_tree(
|
||||
.map_err(|err| TuliproxError::Config(format!("Could not find path for target {target_name} err:{err}")))
|
||||
}
|
||||
|
||||
type XtreamTreeAndOrder = (BPlusTree<u32, XtreamPlaylistItem>, Vec<(u32, u32)>);
|
||||
|
||||
fn read_tree_and_order(
|
||||
path: &Path,
|
||||
index_path: &Path,
|
||||
cluster: XtreamCluster,
|
||||
) -> Result<XtreamTreeAndOrder, TuliproxError> {
|
||||
let mut tree = BPlusTree::new();
|
||||
crate::visit_xtream_cluster_entries(path, index_path, cluster, |key, item| {
|
||||
tree.insert(key, item);
|
||||
true
|
||||
})?;
|
||||
let order = PlaylistXtreamStorage::build_cluster_order(cluster, &tree);
|
||||
Ok((tree, order))
|
||||
}
|
||||
|
||||
async fn load_xtream_playlist_as_tree(
|
||||
app_config: &AppConfig,
|
||||
storage_path: &Path,
|
||||
cluster: XtreamCluster,
|
||||
) -> Result<BPlusTree<u32, XtreamPlaylistItem>, TuliproxError> {
|
||||
) -> Result<(BPlusTree<u32, XtreamPlaylistItem>, Vec<(u32, u32)>), TuliproxError> {
|
||||
let xtream_path = xtream_get_file_path(storage_path, cluster);
|
||||
let index_path = get_file_path_for_db_index(&xtream_path);
|
||||
let file_lock = app_config.file_locks.read_lock(&xtream_path).await;
|
||||
// Move B+Tree query and iteration to spawn_blocking to avoid blocking tokio runtime
|
||||
let path_clone = xtream_path.clone();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
let _guard = file_lock;
|
||||
BPlusTree::<u32, XtreamPlaylistItem>::load(&path_clone)
|
||||
if !path_clone.exists() {
|
||||
debug!("No xtream {cluster} storage at {}, serving empty playlist", path_clone.display());
|
||||
return Ok((BPlusTree::new(), Vec::new()));
|
||||
}
|
||||
read_tree_and_order(&path_clone, &index_path, cluster)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(tree)) => Ok(tree),
|
||||
Ok(Err(err)) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
debug!("No xtream {cluster} storage at {}, serving empty playlist", xtream_path.display());
|
||||
Ok(BPlusTree::new())
|
||||
}
|
||||
Ok(Ok((tree, order))) => Ok((tree, order)),
|
||||
Ok(Err(err)) => Err(TuliproxError::RepositoryXtream(format!(
|
||||
"Failed to load xtream {cluster} storage {}: {err}",
|
||||
xtream_path.display()
|
||||
@@ -101,11 +118,11 @@ pub async fn load_xtream_target_storage(
|
||||
TuliproxError::Config(format!("Could not find path for target {} xtream output", target.name))
|
||||
})?;
|
||||
|
||||
let live_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Live).await?;
|
||||
let vod_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Video).await?;
|
||||
let series_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Series).await?;
|
||||
let (live, live_order) = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Live).await?;
|
||||
let (vod, vod_order) = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Video).await?;
|
||||
let (series, series_order) = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Series).await?;
|
||||
|
||||
Ok(PlaylistXtreamStorage { live: live_storage, vod: vod_storage, series: series_storage })
|
||||
Ok(PlaylistXtreamStorage { live, live_order, vod, vod_order, series, series_order })
|
||||
}
|
||||
|
||||
pub async fn load_m3u_target_storage(
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
open_playlist_reader, write_playlist_batch_item_upsert, write_playlist_item_update, PlaylistIteratorReader,
|
||||
XtreamPlaylistIterator,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use shared::model::SeriesListProjection;
|
||||
use tuliprox_core::model::ProxyUserCredentials;
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_empty_output_filters_reject_m3u_and_strm_before_mutation() {
|
||||
@@ -100,3 +107,543 @@ fn media_server_playlist_file_path_uses_separate_prefix() {
|
||||
|
||||
assert!(path.ends_with("media_server_Media_Server_Input.db"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_load_drops_series_details_while_disk_keeps_them() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let app_config = app_config_in(dir.path());
|
||||
let target = target_with_options(
|
||||
"series-cache-load-target",
|
||||
vec![TargetOutput::Xtream(XtreamTargetOutput {
|
||||
flags: XtreamTargetFlagsSet::new(),
|
||||
trakt: None,
|
||||
filter: None,
|
||||
})],
|
||||
true,
|
||||
);
|
||||
let storage_path = {
|
||||
let config = app_config.config.load();
|
||||
xtream_get_storage_path(&config, &target.name).expect("storage path")
|
||||
};
|
||||
let series_path = crate::xtream_get_file_path(&storage_path, XtreamCluster::Series);
|
||||
tokio::fs::create_dir_all(series_path.parent().expect("parent dir")).await.expect("create dir");
|
||||
|
||||
let details = SeriesStreamDetailProperties { year: Some(2024), seasons: None, episodes: None };
|
||||
let mut item = crate::test_config::xtream_series_item(55, Some(details));
|
||||
item.name = "Test Series".intern();
|
||||
item.title = "Test Series".intern();
|
||||
let mut tree = crate::BPlusTree::new();
|
||||
tree.insert(55u32, item.clone());
|
||||
tree.store_with_index(&series_path, |pli| pli.source_ordinal).expect("store series tree");
|
||||
|
||||
// Load storage into memory cache
|
||||
let storage = load_xtream_target_storage(&app_config, &target).await.expect("load xtream storage");
|
||||
|
||||
// Cache item has details == None
|
||||
let cached_item = storage.series.query(&55).expect("item in cache");
|
||||
assert!(!cached_item.has_details(), "cache item must have details == None");
|
||||
match &cached_item.additional_properties {
|
||||
Some(StreamProperties::Series(props)) => assert_eq!(props.details, None),
|
||||
other => panic!("expected Series properties, got {other:?}"),
|
||||
}
|
||||
|
||||
// Disk item still has details
|
||||
let mut disk_query = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&series_path).expect("open disk query");
|
||||
let disk_item = disk_query.query(&55).expect("query succeeds").expect("item found on disk");
|
||||
assert!(disk_item.has_details(), "disk item must keep details");
|
||||
match &disk_item.additional_properties {
|
||||
Some(StreamProperties::Series(props)) => assert!(props.details.is_some()),
|
||||
other => panic!("expected Series properties, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xtream_get_item_for_stream_id_with_memory_cache_returns_details_for_series_info() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let app_config = Arc::new(app_config_in(dir.path()));
|
||||
let target = target_with_options(
|
||||
"series-lookup-target",
|
||||
vec![TargetOutput::Xtream(XtreamTargetOutput {
|
||||
flags: XtreamTargetFlagsSet::new(),
|
||||
trakt: None,
|
||||
filter: None,
|
||||
})],
|
||||
true,
|
||||
);
|
||||
let (target_path, storage_path) = {
|
||||
let config = app_config.config.load();
|
||||
(
|
||||
get_target_storage_path(&config, &target.name).expect("target path"),
|
||||
xtream_get_storage_path(&config, &target.name).expect("storage path"),
|
||||
)
|
||||
};
|
||||
let series_path = crate::xtream_get_file_path(&storage_path, XtreamCluster::Series);
|
||||
let mapping_path = crate::get_target_id_mapping_file(&target_path);
|
||||
tokio::fs::create_dir_all(series_path.parent().expect("parent dir")).await.expect("create dir");
|
||||
|
||||
// 1. Write series item with details to disk
|
||||
let details = SeriesStreamDetailProperties { year: Some(2024), seasons: None, episodes: None };
|
||||
let mut item = crate::test_config::xtream_series_item(99, Some(details));
|
||||
item.name = "Detailed Series".intern();
|
||||
item.title = "Detailed Series".intern();
|
||||
let mut tree = crate::BPlusTree::new();
|
||||
tree.insert(99u32, item.clone());
|
||||
tree.store_with_index(&series_path, |pli| pli.source_ordinal).expect("store series tree");
|
||||
|
||||
// 2. Write id mapping to disk and populate memory cache
|
||||
let mut mapping_tree = crate::BPlusTree::new();
|
||||
let record = VirtualIdRecord {
|
||||
virtual_id: VirtualId::new(99),
|
||||
provider_id: 99,
|
||||
uuid: UUIDType::default(),
|
||||
item_type: PlaylistItemType::SeriesInfo,
|
||||
parent_virtual_id: VirtualId::default(),
|
||||
last_updated: 0,
|
||||
};
|
||||
mapping_tree.insert(VirtualId::new(99), record);
|
||||
mapping_tree.store(&mapping_path).expect("store id mapping");
|
||||
|
||||
let playlists = Arc::new(PlaylistStorageState::new());
|
||||
// Load storage into memory cache (so memory cache has series item WITHOUT details)
|
||||
let storage = load_xtream_target_storage(&app_config, &target).await.expect("load xtream storage");
|
||||
assert!(!storage.series.query(&99).unwrap().has_details());
|
||||
|
||||
playlists.cache_playlist(&target.name, crate::PlaylistStorage::XtreamPlaylist(Box::new(storage))).await;
|
||||
playlists.cache_id_mapping(&target.name, mapping_tree).await;
|
||||
|
||||
// 3. Look up item: with use_memory_cache: true, series cluster falls through to disk and returns full details
|
||||
let resolved_no_cluster = crate::xtream_get_item_for_stream_id(99, &app_config, &playlists, &target, None)
|
||||
.await
|
||||
.expect("xtream_get_item_for_stream_id succeeds without cluster");
|
||||
assert!(resolved_no_cluster.has_details(), "series item looked up without cluster must have details");
|
||||
|
||||
let resolved_with_cluster =
|
||||
crate::xtream_get_item_for_stream_id(99, &app_config, &playlists, &target, Some(XtreamCluster::Series))
|
||||
.await
|
||||
.expect("xtream_get_item_for_stream_id succeeds with cluster");
|
||||
assert!(resolved_with_cluster.has_details(), "series item looked up with cluster must have details");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xtream_get_item_for_stream_id_resolves_series_info_when_id_mapping_only_in_memory() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let app_config = Arc::new(app_config_in(dir.path()));
|
||||
let target = target_with_options(
|
||||
"series-lookup-mem-mapping-target",
|
||||
vec![TargetOutput::Xtream(XtreamTargetOutput {
|
||||
flags: XtreamTargetFlagsSet::new(),
|
||||
trakt: None,
|
||||
filter: None,
|
||||
})],
|
||||
true,
|
||||
);
|
||||
let (target_path, storage_path) = {
|
||||
let config = app_config.config.load();
|
||||
(
|
||||
get_target_storage_path(&config, &target.name).expect("target path"),
|
||||
xtream_get_storage_path(&config, &target.name).expect("storage path"),
|
||||
)
|
||||
};
|
||||
let series_path = crate::xtream_get_file_path(&storage_path, XtreamCluster::Series);
|
||||
let mapping_path = crate::get_target_id_mapping_file(&target_path);
|
||||
tokio::fs::create_dir_all(series_path.parent().expect("parent dir")).await.expect("create dir");
|
||||
|
||||
// 1. Write series item with details to disk
|
||||
let details = SeriesStreamDetailProperties { year: Some(2024), seasons: None, episodes: None };
|
||||
let mut item = crate::test_config::xtream_series_item(99, Some(details));
|
||||
item.name = "Detailed Series".intern();
|
||||
item.title = "Detailed Series".intern();
|
||||
let mut tree = crate::BPlusTree::new();
|
||||
tree.insert(99u32, item.clone());
|
||||
tree.store_with_index(&series_path, |pli| pli.source_ordinal).expect("store series tree");
|
||||
|
||||
// 2. Populate memory cache with storage and in-memory id mapping, but DO NOT store id mapping to disk
|
||||
let mut mapping_tree = crate::BPlusTree::new();
|
||||
let record = VirtualIdRecord {
|
||||
virtual_id: VirtualId::new(99),
|
||||
provider_id: 99,
|
||||
uuid: UUIDType::default(),
|
||||
item_type: PlaylistItemType::SeriesInfo,
|
||||
parent_virtual_id: VirtualId::default(),
|
||||
last_updated: 0,
|
||||
};
|
||||
mapping_tree.insert(VirtualId::new(99), record);
|
||||
|
||||
let playlists = Arc::new(PlaylistStorageState::new());
|
||||
let storage = load_xtream_target_storage(&app_config, &target).await.expect("load xtream storage");
|
||||
assert!(!storage.series.query(&99).unwrap().has_details());
|
||||
|
||||
playlists.cache_playlist(&target.name, crate::PlaylistStorage::XtreamPlaylist(Box::new(storage))).await;
|
||||
playlists.cache_id_mapping(&target.name, mapping_tree).await;
|
||||
|
||||
// Verify on-disk id mapping file is absent
|
||||
assert!(!mapping_path.exists(), "on-disk id mapping file must not exist");
|
||||
|
||||
// 3. Series info lookup without cluster must resolve via in-memory mapping and return item from disk
|
||||
let resolved = crate::xtream_get_item_for_stream_id(99, &app_config, &playlists, &target, None)
|
||||
.await
|
||||
.expect("series info lookup succeeds when id mapping exists only in memory");
|
||||
assert_eq!(resolved.virtual_id.get(), 99);
|
||||
assert!(resolved.has_details(), "series item looked up must have details");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xtream_get_item_for_stream_id_disk_resolves_catchup_with_live_cluster() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let app_config = Arc::new(app_config_in(dir.path()));
|
||||
let target = target_with_options(
|
||||
"catchup-disk-target",
|
||||
vec![TargetOutput::Xtream(XtreamTargetOutput {
|
||||
flags: XtreamTargetFlagsSet::new(),
|
||||
trakt: None,
|
||||
filter: None,
|
||||
})],
|
||||
false,
|
||||
);
|
||||
let (target_path, storage_path) = {
|
||||
let config = app_config.config.load();
|
||||
(
|
||||
get_target_storage_path(&config, &target.name).expect("target path"),
|
||||
xtream_get_storage_path(&config, &target.name).expect("storage path"),
|
||||
)
|
||||
};
|
||||
let live_path = crate::xtream_get_file_path(&storage_path, XtreamCluster::Live);
|
||||
let mapping_path = crate::get_target_id_mapping_file(&target_path);
|
||||
tokio::fs::create_dir_all(live_path.parent().expect("parent dir")).await.expect("create dir");
|
||||
|
||||
let parent_virtual_id = 100u32;
|
||||
let catchup_virtual_id = 200u32;
|
||||
let mapping_provider_id = 555u32;
|
||||
|
||||
let mut parent_item = crate::test_config::xtream_playlist_item(parent_virtual_id);
|
||||
parent_item.provider_id = 111;
|
||||
parent_item.item_type = PlaylistItemType::Live;
|
||||
let mut tree = crate::BPlusTree::new();
|
||||
tree.insert(parent_virtual_id, parent_item);
|
||||
tree.store(&live_path).expect("store live tree");
|
||||
|
||||
let mut mapping_tree = crate::BPlusTree::new();
|
||||
let record = VirtualIdRecord {
|
||||
virtual_id: VirtualId::new(catchup_virtual_id),
|
||||
provider_id: mapping_provider_id,
|
||||
uuid: UUIDType::default(),
|
||||
item_type: PlaylistItemType::Catchup,
|
||||
parent_virtual_id: VirtualId::new(parent_virtual_id),
|
||||
last_updated: 0,
|
||||
};
|
||||
mapping_tree.insert(VirtualId::new(catchup_virtual_id), record);
|
||||
mapping_tree.store(&mapping_path).expect("store id mapping");
|
||||
|
||||
let playlists = Arc::new(PlaylistStorageState::new());
|
||||
|
||||
let item = crate::xtream_get_item_for_stream_id(
|
||||
catchup_virtual_id,
|
||||
&app_config,
|
||||
&playlists,
|
||||
&target,
|
||||
Some(XtreamCluster::Live),
|
||||
)
|
||||
.await
|
||||
.expect("catchup item lookup succeeds on disk path");
|
||||
|
||||
assert_eq!(item.item_type, PlaylistItemType::Catchup);
|
||||
assert_eq!(item.provider_id, mapping_provider_id);
|
||||
assert_eq!(item.virtual_id, VirtualId::new(catchup_virtual_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xtream_get_item_for_stream_id_disk_resolves_local_series_episode_via_parent() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let app_config = Arc::new(app_config_in(dir.path()));
|
||||
let target = target_with_options(
|
||||
"local-series-disk-target",
|
||||
vec![TargetOutput::Xtream(XtreamTargetOutput {
|
||||
flags: XtreamTargetFlagsSet::new(),
|
||||
trakt: None,
|
||||
filter: None,
|
||||
})],
|
||||
false,
|
||||
);
|
||||
let (target_path, storage_path) = {
|
||||
let config = app_config.config.load();
|
||||
(
|
||||
get_target_storage_path(&config, &target.name).expect("target path"),
|
||||
xtream_get_storage_path(&config, &target.name).expect("storage path"),
|
||||
)
|
||||
};
|
||||
let series_path = crate::xtream_get_file_path(&storage_path, XtreamCluster::Series);
|
||||
let mapping_path = crate::get_target_id_mapping_file(&target_path);
|
||||
tokio::fs::create_dir_all(series_path.parent().expect("parent dir")).await.expect("create dir");
|
||||
|
||||
let parent_virtual_id = 100u32;
|
||||
let episode_virtual_id = 200u32;
|
||||
let mapping_provider_id = 555u32;
|
||||
|
||||
let mut parent_item = crate::test_config::xtream_playlist_item(parent_virtual_id);
|
||||
parent_item.provider_id = 111;
|
||||
parent_item.item_type = PlaylistItemType::LocalSeriesInfo;
|
||||
let mut tree = crate::BPlusTree::new();
|
||||
tree.insert(parent_virtual_id, parent_item);
|
||||
tree.store(&series_path).expect("store series tree");
|
||||
|
||||
let mut mapping_tree = crate::BPlusTree::new();
|
||||
let record = VirtualIdRecord {
|
||||
virtual_id: VirtualId::new(episode_virtual_id),
|
||||
provider_id: mapping_provider_id,
|
||||
uuid: UUIDType::default(),
|
||||
item_type: PlaylistItemType::LocalSeries,
|
||||
parent_virtual_id: VirtualId::new(parent_virtual_id),
|
||||
last_updated: 0,
|
||||
};
|
||||
mapping_tree.insert(VirtualId::new(episode_virtual_id), record);
|
||||
mapping_tree.store(&mapping_path).expect("store id mapping");
|
||||
|
||||
let playlists = Arc::new(PlaylistStorageState::new());
|
||||
|
||||
let item = crate::xtream_get_item_for_stream_id(
|
||||
episode_virtual_id,
|
||||
&app_config,
|
||||
&playlists,
|
||||
&target,
|
||||
Some(XtreamCluster::Series),
|
||||
)
|
||||
.await
|
||||
.expect("local series episode item lookup succeeds on disk path");
|
||||
|
||||
assert_eq!(item.item_type, PlaylistItemType::LocalSeries);
|
||||
assert_eq!(item.provider_id, mapping_provider_id);
|
||||
assert_eq!(item.virtual_id, VirtualId::new(episode_virtual_id));
|
||||
}
|
||||
|
||||
fn seed_test_cluster(storage_path: &Path, cluster: XtreamCluster) {
|
||||
let path = crate::xtream_get_file_path(storage_path, cluster);
|
||||
let mut tree = crate::BPlusTree::new();
|
||||
for (id, ordinal) in [(10, 300), (20, 100), (30, 200)] {
|
||||
let mut item = match cluster {
|
||||
XtreamCluster::Live => crate::test_config::xtream_playlist_item(id),
|
||||
XtreamCluster::Video => {
|
||||
let mut it = crate::test_config::xtream_playlist_item(id);
|
||||
it.xtream_cluster = XtreamCluster::Video;
|
||||
it.item_type = PlaylistItemType::Video;
|
||||
it
|
||||
}
|
||||
XtreamCluster::Series => crate::test_config::xtream_series_item(id, None),
|
||||
};
|
||||
item.source_ordinal = ordinal;
|
||||
tree.insert(id, item);
|
||||
}
|
||||
tree.store_with_index(&path, |pli| pli.source_ordinal).expect("store with index");
|
||||
}
|
||||
|
||||
async fn apply_test_upserts(
|
||||
app_config: &Arc<AppConfig>,
|
||||
target: &ConfigTarget,
|
||||
playlists: &PlaylistStorageState,
|
||||
cluster: XtreamCluster,
|
||||
) {
|
||||
let mut repl = match cluster {
|
||||
XtreamCluster::Live => crate::test_config::xtream_playlist_item(20),
|
||||
XtreamCluster::Video => {
|
||||
let mut it = crate::test_config::xtream_playlist_item(20);
|
||||
it.xtream_cluster = XtreamCluster::Video;
|
||||
it.item_type = PlaylistItemType::Video;
|
||||
it
|
||||
}
|
||||
XtreamCluster::Series => crate::test_config::xtream_series_item(20, None),
|
||||
};
|
||||
repl.source_ordinal = 100;
|
||||
repl.title = "Updated Title".intern();
|
||||
|
||||
write_playlist_item_update(app_config, &target.name, &repl).await.expect("disk replacement");
|
||||
playlists.update_playlist_items(target, &[&repl]).await;
|
||||
|
||||
let mut new1 = match cluster {
|
||||
XtreamCluster::Live => crate::test_config::xtream_playlist_item(40),
|
||||
XtreamCluster::Video => {
|
||||
let mut it = crate::test_config::xtream_playlist_item(40);
|
||||
it.xtream_cluster = XtreamCluster::Video;
|
||||
it.item_type = PlaylistItemType::Video;
|
||||
it
|
||||
}
|
||||
XtreamCluster::Series => crate::test_config::xtream_series_item(40, None),
|
||||
};
|
||||
new1.source_ordinal = 50;
|
||||
|
||||
let mut new2 = match cluster {
|
||||
XtreamCluster::Live => crate::test_config::xtream_playlist_item(5),
|
||||
XtreamCluster::Video => {
|
||||
let mut it = crate::test_config::xtream_playlist_item(5);
|
||||
it.xtream_cluster = XtreamCluster::Video;
|
||||
it.item_type = PlaylistItemType::Video;
|
||||
it
|
||||
}
|
||||
XtreamCluster::Series => crate::test_config::xtream_series_item(5, None),
|
||||
};
|
||||
new2.source_ordinal = 250;
|
||||
|
||||
write_playlist_batch_item_upsert(app_config, &target.name, cluster, &[new1.clone(), new2.clone()])
|
||||
.await
|
||||
.expect("disk batch upsert");
|
||||
playlists.update_playlist_items(target, &[&new1, &new2]).await;
|
||||
|
||||
if cluster == XtreamCluster::Series {
|
||||
let mut episode = crate::test_config::xtream_playlist_item(500);
|
||||
episode.xtream_cluster = XtreamCluster::Series;
|
||||
episode.item_type = PlaylistItemType::Series;
|
||||
episode.source_ordinal = 10;
|
||||
write_playlist_batch_item_upsert(app_config, &target.name, cluster, &[episode.clone()])
|
||||
.await
|
||||
.expect("disk episode upsert");
|
||||
playlists.update_playlist_items(target, &[&episode]).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_cluster_iterators_match(
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlists: &PlaylistStorageState,
|
||||
target: &ConfigTarget,
|
||||
target_disk: &ConfigTarget,
|
||||
storage_path: &Path,
|
||||
cluster: XtreamCluster,
|
||||
expected_order: &[u32],
|
||||
) {
|
||||
let path = crate::xtream_get_file_path(storage_path, cluster);
|
||||
let index_path = crate::get_file_path_for_db_index(&path);
|
||||
let user = ProxyUserCredentials::default();
|
||||
|
||||
// Verify the disk path uses the sorted index (not the unsorted fallback)
|
||||
if cluster == XtreamCluster::Series {
|
||||
let reader = open_playlist_reader::<u32, SeriesListProjection, u32>(&path, &index_path, None)
|
||||
.expect("open series reader");
|
||||
assert!(matches!(reader, PlaylistIteratorReader::Sorted { .. }), "disk path fell back to unsorted for Series");
|
||||
} else {
|
||||
let reader = open_playlist_reader::<u32, XtreamPlaylistItem, u32>(&path, &index_path, None)
|
||||
.expect("open cluster reader");
|
||||
assert!(
|
||||
matches!(reader, PlaylistIteratorReader::Sorted { .. }),
|
||||
"disk path fell back to unsorted for {cluster}"
|
||||
);
|
||||
}
|
||||
|
||||
// Memory iterator
|
||||
let mem_iter = XtreamPlaylistIterator::new(cluster, app_config, playlists, target, None, &user)
|
||||
.await
|
||||
.expect("memory iterator opens");
|
||||
let mem_items: Vec<XtreamPlaylistItem> =
|
||||
mem_iter.collect::<Vec<_>>().await.into_iter().map(|res| res.expect("item").0).collect();
|
||||
|
||||
// Disk iterator
|
||||
let disk_iter = XtreamPlaylistIterator::new(cluster, app_config, playlists, target_disk, None, &user)
|
||||
.await
|
||||
.expect("disk iterator opens");
|
||||
let disk_items: Vec<XtreamPlaylistItem> =
|
||||
disk_iter.collect::<Vec<_>>().await.into_iter().map(|res| res.expect("item").0).collect();
|
||||
|
||||
let mem_ids: Vec<u32> = mem_items.iter().map(|item| item.virtual_id.get()).collect();
|
||||
let disk_ids: Vec<u32> = disk_items.iter().map(|item| item.virtual_id.get()).collect();
|
||||
|
||||
assert_eq!(mem_ids, expected_order, "memory iterator output matches canonical sort order for cluster {cluster}");
|
||||
assert_eq!(disk_ids, expected_order, "disk iterator output matches canonical sort order for cluster {cluster}");
|
||||
assert_eq!(disk_ids, mem_ids, "disk iterator equals memory iterator for cluster {cluster}");
|
||||
assert_eq!(disk_items.len(), mem_items.len());
|
||||
for (disk_item, mem_item) in disk_items.iter().zip(mem_items.iter()) {
|
||||
assert_eq!(disk_item.virtual_id, mem_item.virtual_id);
|
||||
assert_eq!(disk_item.name, mem_item.name);
|
||||
assert_eq!(disk_item.title, mem_item.title);
|
||||
assert_eq!(disk_item.source_ordinal, mem_item.source_ordinal);
|
||||
assert_eq!(disk_item.item_type, mem_item.item_type);
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_raw_series_contains_all_keys(app_config: &AppConfig, target: &ConfigTarget) {
|
||||
let stream = crate::iter_raw_xtream_target_playlist(app_config, target, XtreamCluster::Series)
|
||||
.await
|
||||
.expect("raw series stream opens");
|
||||
let raw_items: Vec<XtreamPlaylistItem> =
|
||||
stream.collect::<Vec<_>>().await.into_iter().map(|res| res.expect("item")).collect();
|
||||
let raw_ids: Vec<u32> = raw_items.iter().map(|item| item.virtual_id.get()).collect();
|
||||
|
||||
// Raw target iterator iterates the tree through the sorted index; assert every key
|
||||
// in the tree (including episode 500) is yielded in (source_ordinal, virtual_id) order.
|
||||
assert_eq!(raw_ids, vec![500, 40, 20, 30, 5, 10]);
|
||||
}
|
||||
|
||||
async fn assert_repositioning_moves_item(
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlists: &PlaylistStorageState,
|
||||
target: &ConfigTarget,
|
||||
target_disk: &ConfigTarget,
|
||||
storage_path: &Path,
|
||||
) {
|
||||
let mut moved = crate::test_config::xtream_series_item(30, None);
|
||||
moved.source_ordinal = 20;
|
||||
moved.title = "Moved Series Title".intern();
|
||||
|
||||
write_playlist_item_update(app_config, &target.name, &moved).await.expect("disk reposition update");
|
||||
playlists.update_playlist_items(target, &[&moved]).await;
|
||||
|
||||
// After moving key 30 from source_ordinal 200 to 20, it precedes key 40 (source_ordinal 50)
|
||||
let new_expected_order = [30, 40, 20, 5, 10];
|
||||
assert_cluster_iterators_match(
|
||||
app_config,
|
||||
playlists,
|
||||
target,
|
||||
target_disk,
|
||||
storage_path,
|
||||
XtreamCluster::Series,
|
||||
&new_expected_order,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disk_and_memory_list_output_match_canonical_sort_order_after_upserts() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let app_config = Arc::new(app_config_in(dir.path()));
|
||||
let target = target_with_options(
|
||||
"sort-order-target",
|
||||
vec![TargetOutput::Xtream(XtreamTargetOutput {
|
||||
flags: XtreamTargetFlagsSet::new(),
|
||||
trakt: None,
|
||||
filter: None,
|
||||
})],
|
||||
true,
|
||||
);
|
||||
let storage_path = {
|
||||
let config = app_config.config.load();
|
||||
xtream_get_storage_path(&config, &target.name).expect("storage path")
|
||||
};
|
||||
tokio::fs::create_dir_all(&storage_path).await.expect("create storage dir");
|
||||
|
||||
for cluster in [XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series] {
|
||||
seed_test_cluster(&storage_path, cluster);
|
||||
}
|
||||
|
||||
let storage = load_xtream_target_storage(&app_config, &target).await.expect("load xtream storage");
|
||||
let playlists = Arc::new(PlaylistStorageState::new());
|
||||
playlists.cache_playlist(&target.name, crate::PlaylistStorage::XtreamPlaylist(Box::new(storage))).await;
|
||||
|
||||
for cluster in [XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series] {
|
||||
apply_test_upserts(&app_config, &target, &playlists, cluster).await;
|
||||
}
|
||||
|
||||
let expected_order = [40, 20, 30, 5, 10];
|
||||
let mut target_disk = target.clone();
|
||||
target_disk.use_memory_cache = false;
|
||||
|
||||
for cluster in [XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series] {
|
||||
assert_cluster_iterators_match(
|
||||
&app_config,
|
||||
&playlists,
|
||||
&target,
|
||||
&target_disk,
|
||||
&storage_path,
|
||||
cluster,
|
||||
&expected_order,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
assert_raw_series_contains_all_keys(&app_config, &target).await;
|
||||
assert_repositioning_moves_item(&app_config, &playlists, &target, &target_disk, &storage_path).await;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::{BPlusTreeDiskIteratorOwned, BPlusTreeQuery, SortedIndexIterator};
|
||||
use futures::Stream;
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::{
|
||||
error::TuliproxError,
|
||||
model::{SeriesListProjection, XtreamCluster, XtreamPlaylistItem},
|
||||
};
|
||||
use std::{
|
||||
io,
|
||||
path::Path,
|
||||
@@ -171,6 +174,53 @@ where
|
||||
Ok(PlaylistIteratorReader::Unsorted(query.disk_iter()))
|
||||
}
|
||||
|
||||
fn read_xtream_cluster_entries<V, F>(
|
||||
xtream_path: &Path,
|
||||
index_path: &Path,
|
||||
cluster: XtreamCluster,
|
||||
mut visitor: F,
|
||||
) -> Result<(), TuliproxError>
|
||||
where
|
||||
V: Serialize + for<'de> Deserialize<'de> + Clone + Into<XtreamPlaylistItem>,
|
||||
F: FnMut(u32, XtreamPlaylistItem) -> bool,
|
||||
{
|
||||
let reader = open_playlist_reader::<u32, V, u32>(
|
||||
xtream_path,
|
||||
index_path,
|
||||
Some("Sorted index error, falling back to unsorted"),
|
||||
)?;
|
||||
for entry in reader {
|
||||
match entry {
|
||||
Ok((key, item)) => {
|
||||
if !visitor(key, item.into()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Skipping unreadable Xtream playlist entry in cluster {cluster}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper that opens an Xtream cluster reader (`SeriesListProjection` for Series) and visits entries.
|
||||
pub fn visit_xtream_cluster_entries<F>(
|
||||
xtream_path: &Path,
|
||||
index_path: &Path,
|
||||
cluster: XtreamCluster,
|
||||
visitor: F,
|
||||
) -> Result<(), TuliproxError>
|
||||
where
|
||||
F: FnMut(u32, XtreamPlaylistItem) -> bool,
|
||||
{
|
||||
if cluster == XtreamCluster::Series {
|
||||
read_xtream_cluster_entries::<SeriesListProjection, F>(xtream_path, index_path, cluster, visitor)
|
||||
} else {
|
||||
read_xtream_cluster_entries::<XtreamPlaylistItem, F>(xtream_path, index_path, cluster, visitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -103,3 +103,18 @@ pub(crate) fn xtream_playlist_item(id: u32) -> XtreamPlaylistItem {
|
||||
upstream_user_agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Series Xtream playlist item whose provider and virtual id are `id`.
|
||||
pub(crate) fn xtream_series_item(
|
||||
id: u32,
|
||||
details: Option<shared::model::SeriesStreamDetailProperties>,
|
||||
) -> XtreamPlaylistItem {
|
||||
let mut item = xtream_playlist_item(id);
|
||||
item.xtream_cluster = XtreamCluster::Series;
|
||||
item.item_type = PlaylistItemType::SeriesInfo;
|
||||
item.url = "".intern();
|
||||
let series_props =
|
||||
shared::model::SeriesStreamProperties { name: format!("Series {id}").intern(), details, ..Default::default() };
|
||||
item.additional_properties = Some(shared::model::StreamProperties::Series(Box::new(series_props)));
|
||||
item
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::{
|
||||
get_file_path_for_db_index, open_playlist_reader, user_get_bouquet_filter, xtream_get_file_path,
|
||||
xtream_get_item_for_stream_id, xtream_get_storage_path, LockedReceiverStream, PlaylistStorageState,
|
||||
get_file_path_for_db_index, user_get_bouquet_filter, xtream_get_file_path, xtream_get_item_for_stream_id,
|
||||
xtream_get_storage_path, LockedReceiverStream, PlaylistStorageState,
|
||||
};
|
||||
use futures::{Stream, StreamExt};
|
||||
use log::error;
|
||||
use shared::{
|
||||
error::TuliproxError,
|
||||
model::{PlaylistItemType, TargetType, XtreamCluster, XtreamPlaylistItem},
|
||||
model::{TargetType, XtreamCluster, XtreamPlaylistItem},
|
||||
};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
@@ -20,8 +20,13 @@ use tuliprox_core::{
|
||||
utils::prepare_xtream_resource_hosts,
|
||||
};
|
||||
|
||||
enum XtreamPlaylistIteratorInner {
|
||||
Disk(LockedReceiverStream<Result<(XtreamPlaylistItem, bool), TuliproxError>>),
|
||||
Memory(std::vec::IntoIter<XtreamPlaylistItem>),
|
||||
}
|
||||
|
||||
pub struct XtreamPlaylistIterator {
|
||||
inner: LockedReceiverStream<Result<(XtreamPlaylistItem, bool), TuliproxError>>,
|
||||
inner: XtreamPlaylistIteratorInner,
|
||||
}
|
||||
|
||||
fn is_cluster_allowed_for_user(user: &ProxyUserCredentials, cluster: XtreamCluster) -> bool {
|
||||
@@ -64,10 +69,7 @@ impl XtreamItemFilter {
|
||||
|
||||
#[inline]
|
||||
fn matches(&self, item: &XtreamPlaylistItem) -> bool {
|
||||
// We can't serve episodes within series
|
||||
if self.cluster == XtreamCluster::Series
|
||||
&& !matches!(item.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|
||||
{
|
||||
if !crate::playlist_mem_cache::is_cluster_order_eligible(self.cluster, item.item_type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -91,28 +93,79 @@ impl XtreamItemFilter {
|
||||
}
|
||||
}
|
||||
|
||||
impl XtreamPlaylistIterator {
|
||||
fn empty() -> Self {
|
||||
// Ghost Channel pattern
|
||||
// When you immediately drop the sender with `_`, the channel is closed and receiver gets None.
|
||||
let (_tx, rx) = mpsc::channel::<Result<(XtreamPlaylistItem, bool), TuliproxError>>(1);
|
||||
Self { inner: LockedReceiverStream::new_empty(rx) }
|
||||
fn stream_playlist_entries(
|
||||
xtream_path: &std::path::Path,
|
||||
index_path: &std::path::Path,
|
||||
cluster: XtreamCluster,
|
||||
item_filter: &XtreamItemFilter,
|
||||
tx: &mpsc::Sender<Result<(XtreamPlaylistItem, bool), TuliproxError>>,
|
||||
) {
|
||||
let mut pending: Option<XtreamPlaylistItem> = None;
|
||||
let visit_result = crate::visit_xtream_cluster_entries(xtream_path, index_path, cluster, |_key, item| {
|
||||
if !item_filter.matches(&item) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(prev) = pending.replace(item) {
|
||||
if tx.blocking_send(Ok((prev, true))).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
|
||||
if let Err(err) = visit_result {
|
||||
error!("Failed to open Xtream playlist DB {} (cluster {cluster}): {err}", xtream_path.display());
|
||||
let _ = tx.blocking_send(Err(err));
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(last) = pending {
|
||||
let _ = tx.blocking_send(Ok((last, false)));
|
||||
}
|
||||
}
|
||||
|
||||
impl XtreamPlaylistIterator {
|
||||
fn empty() -> Self { Self { inner: XtreamPlaylistIteratorInner::Memory(Vec::new().into_iter()) } }
|
||||
|
||||
pub async fn new(
|
||||
cluster: XtreamCluster,
|
||||
app_config: &AppConfig,
|
||||
playlists: &PlaylistStorageState,
|
||||
target: &ConfigTarget,
|
||||
category_id: Option<u32>,
|
||||
user: &ProxyUserCredentials,
|
||||
) -> Result<Self, TuliproxError> {
|
||||
// TODO use playlist memory cache and keep sorted
|
||||
if !is_cluster_allowed_for_user(user, cluster) {
|
||||
return Ok(Self::empty());
|
||||
}
|
||||
|
||||
debug_assert!(target.get_xtream_output().is_some());
|
||||
let config = app_config.config.load();
|
||||
let item_filter = XtreamItemFilter::new(&config, cluster, category_id, user).await;
|
||||
|
||||
if target.use_memory_cache {
|
||||
let memory_items = {
|
||||
let data = playlists.data.read().await;
|
||||
data.get(&target.name).and_then(|storage| storage.xtream.as_ref()).map(|xtream_storage| {
|
||||
let (tree, order) = xtream_storage.get_cluster(cluster);
|
||||
let mut matching = Vec::new();
|
||||
for (_, key) in order {
|
||||
if let Some(item) = tree.query(key) {
|
||||
if item_filter.matches(item) {
|
||||
matching.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
matching
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(items) = memory_items {
|
||||
return Ok(Self { inner: XtreamPlaylistIteratorInner::Memory(items.into_iter()) });
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(storage_path) = xtream_get_storage_path(&config, target.name.as_str()) {
|
||||
let xtream_path = xtream_get_file_path(&storage_path, cluster);
|
||||
if !xtream_path.exists() {
|
||||
@@ -123,8 +176,6 @@ impl XtreamPlaylistIterator {
|
||||
let file_lock = Arc::new(app_config.file_locks.read_lock(&xtream_path).await);
|
||||
let bg_lock = Arc::clone(&file_lock);
|
||||
|
||||
let item_filter = XtreamItemFilter::new(&config, cluster, category_id, user).await;
|
||||
|
||||
let xtream_path = xtream_path.clone();
|
||||
let index_path = get_file_path_for_db_index(&xtream_path);
|
||||
let (tx, rx) = mpsc::channel::<Result<(XtreamPlaylistItem, bool), TuliproxError>>(256);
|
||||
@@ -133,46 +184,7 @@ impl XtreamPlaylistIterator {
|
||||
let join_error_tx = tx.clone();
|
||||
let handle = task::spawn_blocking(move || {
|
||||
let _guard = bg_lock;
|
||||
let reader = match open_playlist_reader::<u32, XtreamPlaylistItem, u32>(
|
||||
&xtream_path,
|
||||
&index_path,
|
||||
Some("Sorted index error, falling back to unsorted"),
|
||||
) {
|
||||
Ok(reader) => reader,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to open Xtream playlist DB {} (cluster {cluster}): {err}",
|
||||
xtream_path.display()
|
||||
);
|
||||
let _ = tx.blocking_send(Err(err));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut pending: Option<XtreamPlaylistItem> = None;
|
||||
for entry in reader {
|
||||
let item = match entry {
|
||||
Ok((_, item)) => item,
|
||||
Err(err) => {
|
||||
error!("Skipping unreadable Xtream playlist entry: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !item_filter.matches(&item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(prev) = pending.replace(item) {
|
||||
if tx.blocking_send(Ok((prev, true))).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(last) = pending {
|
||||
let _ = tx.blocking_send(Ok((last, false)));
|
||||
}
|
||||
stream_playlist_entries(&xtream_path, &index_path, cluster, &item_filter, &tx);
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle.await {
|
||||
@@ -189,7 +201,7 @@ impl XtreamPlaylistIterator {
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self { inner: LockedReceiverStream::new_shared(rx, file_lock) })
|
||||
Ok(Self { inner: XtreamPlaylistIteratorInner::Disk(LockedReceiverStream::new_shared(rx, file_lock)) })
|
||||
} else {
|
||||
Err(TuliproxError::Config(format!("Failed to find xtream storage for target {}", target.name)))
|
||||
}
|
||||
@@ -247,7 +259,16 @@ impl<'a> XtreamUserItemLookup<'a> {
|
||||
impl Stream for XtreamPlaylistIterator {
|
||||
type Item = Result<(XtreamPlaylistItem, bool), TuliproxError>;
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
Pin::new(&mut self.inner).poll_next(cx)
|
||||
match &mut self.inner {
|
||||
XtreamPlaylistIteratorInner::Disk(stream) => Pin::new(stream).poll_next(cx),
|
||||
XtreamPlaylistIteratorInner::Memory(iter) => {
|
||||
let next_item = iter.next().map(|item| {
|
||||
let has_next = !iter.as_slice().is_empty();
|
||||
(item, has_next)
|
||||
});
|
||||
Poll::Ready(next_item.map(Ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +282,7 @@ impl XtreamPlaylistJsonIterator {
|
||||
pub async fn new(
|
||||
cluster: XtreamCluster,
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlists: &PlaylistStorageState,
|
||||
target: &ConfigTarget,
|
||||
category_id: Option<u32>,
|
||||
user: &ProxyUserCredentials,
|
||||
@@ -279,7 +301,7 @@ impl XtreamPlaylistJsonIterator {
|
||||
user,
|
||||
encrypt_secret,
|
||||
)?);
|
||||
let raw = XtreamPlaylistIterator::new(cluster, app_config, target, category_id, user).await?;
|
||||
let raw = XtreamPlaylistIterator::new(cluster, app_config, playlists, target, category_id, user).await?;
|
||||
let inner = raw.then(move |entry| {
|
||||
let options = Arc::clone(&options);
|
||||
async move {
|
||||
@@ -303,11 +325,11 @@ impl Stream for XtreamPlaylistJsonIterator {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_cluster_allowed_for_user, XtreamItemFilter, XtreamPlaylistIterator};
|
||||
use super::{is_cluster_allowed_for_user, XtreamItemFilter, XtreamPlaylistIterator, XtreamPlaylistIteratorInner};
|
||||
use crate::{
|
||||
bplustree::BPlusTree,
|
||||
test_config::{app_config_in, target_with_output, xtream_playlist_item},
|
||||
xtream_get_file_path, xtream_get_storage_path, LockedReceiverStream,
|
||||
xtream_get_file_path, xtream_get_storage_path, LockedReceiverStream, PlaylistStorageState,
|
||||
};
|
||||
use futures::{poll, StreamExt};
|
||||
use shared::{
|
||||
@@ -402,7 +424,8 @@ mod tests {
|
||||
assert!(tx.send(Err(shared::error::TuliproxError::RepositoryXtream("corrupt page"))).await.is_ok());
|
||||
drop(tx);
|
||||
|
||||
let mut iterator = XtreamPlaylistIterator { inner: LockedReceiverStream::new_empty(rx) };
|
||||
let mut iterator =
|
||||
XtreamPlaylistIterator { inner: XtreamPlaylistIteratorInner::Disk(LockedReceiverStream::new_empty(rx)) };
|
||||
assert!(iterator.next().await.is_some_and(|entry| entry.is_ok()));
|
||||
assert!(iterator.next().await.is_some_and(|entry| entry.is_err()));
|
||||
assert!(iterator.next().await.is_none());
|
||||
@@ -431,6 +454,7 @@ mod tests {
|
||||
let mut iterator = XtreamPlaylistIterator::new(
|
||||
XtreamCluster::Live,
|
||||
&app_config,
|
||||
&PlaylistStorageState::default(),
|
||||
&target,
|
||||
None,
|
||||
&ProxyUserCredentials::default(),
|
||||
@@ -449,4 +473,214 @@ mod tests {
|
||||
drop(guard);
|
||||
assert!(app_config.file_locks.try_write_lock(&xtream_path).await.is_ok(), "no read guard may be left behind");
|
||||
}
|
||||
|
||||
fn sample_series_item_with_details(id: u32) -> XtreamPlaylistItem {
|
||||
use shared::model::{
|
||||
SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties,
|
||||
SeriesStreamProperties, StreamProperties,
|
||||
};
|
||||
|
||||
let details = SeriesStreamDetailProperties {
|
||||
year: Some(2024),
|
||||
seasons: Some(vec![SeriesStreamDetailSeasonProperties {
|
||||
name: "Season 1".intern(),
|
||||
season_number: 1,
|
||||
episode_count: 5,
|
||||
overview: Some("Overview".intern()),
|
||||
air_date: Some("2024-01-01".intern()),
|
||||
cover: Some("http://example.test/s1.jpg".intern()),
|
||||
cover_tmdb: None,
|
||||
cover_big: None,
|
||||
duration: None,
|
||||
}]),
|
||||
episodes: Some(vec![SeriesStreamDetailEpisodeProperties {
|
||||
id: 501,
|
||||
episode_num: 1,
|
||||
season: 1,
|
||||
title: "Episode 1".intern(),
|
||||
container_extension: "mp4".intern(),
|
||||
custom_sid: None,
|
||||
added: "1700000000".intern(),
|
||||
direct_source: "".intern(),
|
||||
tmdb: Some(999),
|
||||
release_date: "2024-01-01".intern(),
|
||||
series_release_date: None,
|
||||
plot: Some("Plot".intern()),
|
||||
crew: None,
|
||||
duration_secs: 3600,
|
||||
duration: "60".intern(),
|
||||
movie_image: "http://example.test/e1.jpg".intern(),
|
||||
bitrate: 4500,
|
||||
rating: Some(8.0),
|
||||
video: None,
|
||||
audio: None,
|
||||
}]),
|
||||
};
|
||||
let series_props = SeriesStreamProperties {
|
||||
name: "Test Series".intern(),
|
||||
category_id: 1,
|
||||
series_id: id,
|
||||
cast: "Star".intern(),
|
||||
genre: Some("Sci-Fi".intern()),
|
||||
details: Some(details),
|
||||
..Default::default()
|
||||
};
|
||||
XtreamPlaylistItem {
|
||||
virtual_id: VirtualId::new(id),
|
||||
provider_id: id,
|
||||
name: "Test Series".intern(),
|
||||
logo: "http://example.test/logo.png".intern(),
|
||||
logo_small: "".intern(),
|
||||
group: "Sci-Fi".intern(),
|
||||
title: "Test Series".intern(),
|
||||
parent_code: "".intern(),
|
||||
rec: "".intern(),
|
||||
url: format!("http://example.test/series/{id}").intern(),
|
||||
epg_channel_id: None,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
additional_properties: Some(StreamProperties::Series(Box::new(series_props))),
|
||||
item_type: PlaylistItemType::SeriesInfo,
|
||||
category_id: 1,
|
||||
input_name: "test-input".intern(),
|
||||
channel_no: 10,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: id.to_string().intern(),
|
||||
upstream_user_agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn series_iterator_skips_episode_details() {
|
||||
use shared::model::StreamProperties;
|
||||
|
||||
let temp = tempfile::tempdir().expect("temp dir should be created");
|
||||
let app_config = app_config_in(temp.path());
|
||||
let target = target_with_output(
|
||||
"series-target",
|
||||
TargetOutput::Xtream(XtreamTargetOutput { flags: XtreamTargetFlagsSet::new(), trakt: None, filter: None }),
|
||||
);
|
||||
let storage_path = xtream_get_storage_path(&app_config.config.load(), &target.name).expect("storage path");
|
||||
let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Series);
|
||||
std::fs::create_dir_all(xtream_path.parent().expect("parent dir")).expect("storage dir should be created");
|
||||
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(77u32, sample_series_item_with_details(77));
|
||||
tree.store(&xtream_path).expect("series playlist should persist");
|
||||
|
||||
let mut iterator = XtreamPlaylistIterator::new(
|
||||
XtreamCluster::Series,
|
||||
&app_config,
|
||||
&PlaylistStorageState::default(),
|
||||
&target,
|
||||
None,
|
||||
&ProxyUserCredentials::default(),
|
||||
)
|
||||
.await
|
||||
.expect("iterator should open");
|
||||
|
||||
let entry = iterator.next().await.expect("entry should be yielded").expect("entry is ok");
|
||||
let (item, has_next) = entry;
|
||||
assert!(!has_next);
|
||||
|
||||
// Header fields intact
|
||||
assert_eq!(item.virtual_id, VirtualId::new(77));
|
||||
assert_eq!(item.provider_id, 77);
|
||||
assert_eq!(item.name.as_ref(), "Test Series");
|
||||
assert_eq!(item.category_id, 1);
|
||||
assert_eq!(item.item_type, PlaylistItemType::SeriesInfo);
|
||||
|
||||
// Details skipped
|
||||
match item.additional_properties {
|
||||
Some(StreamProperties::Series(series)) => {
|
||||
assert_eq!(series.name.as_ref(), "Test Series");
|
||||
assert_eq!(series.series_id, 77);
|
||||
assert_eq!(series.genre.as_deref(), Some("Sci-Fi"));
|
||||
assert_eq!(series.details, None, "details must be None on the list iterator");
|
||||
}
|
||||
other => panic!("expected Series properties, got {other:?}"),
|
||||
}
|
||||
|
||||
assert!(iterator.next().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_path_yields_same_items_and_order_as_disk_path_with_sorted_index() {
|
||||
use crate::{load_xtream_target_storage, PlaylistStorage};
|
||||
|
||||
let temp = tempfile::tempdir().expect("temp dir should be created");
|
||||
let app_config = app_config_in(temp.path());
|
||||
let mut target = target_with_output(
|
||||
"sorted-test-target",
|
||||
TargetOutput::Xtream(XtreamTargetOutput { flags: XtreamTargetFlagsSet::new(), trakt: None, filter: None }),
|
||||
);
|
||||
let storage_path = xtream_get_storage_path(&app_config.config.load(), &target.name).expect("storage path");
|
||||
let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Live);
|
||||
std::fs::create_dir_all(xtream_path.parent().expect("parent dir")).expect("storage dir should be created");
|
||||
|
||||
let mut item1 = xtream_playlist_item(1);
|
||||
item1.source_ordinal = 300;
|
||||
let mut item2 = xtream_playlist_item(2);
|
||||
item2.source_ordinal = 100;
|
||||
let mut item3 = xtream_playlist_item(3);
|
||||
item3.source_ordinal = 200;
|
||||
|
||||
let mut tree = BPlusTree::new();
|
||||
tree.insert(1u32, item1);
|
||||
tree.insert(2u32, item2);
|
||||
tree.insert(3u32, item3);
|
||||
tree.store_with_index(&xtream_path, |pli| pli.source_ordinal).expect("store_with_index succeeds");
|
||||
|
||||
let storage = load_xtream_target_storage(&app_config, &target).await.expect("load storage");
|
||||
assert_eq!(storage.live_order, vec![(100, 2), (200, 3), (300, 1)]);
|
||||
|
||||
let playlists = PlaylistStorageState::new();
|
||||
playlists.cache_playlist(&target.name, PlaylistStorage::XtreamPlaylist(Box::new(storage))).await;
|
||||
|
||||
// 1. Read via disk path (use_memory_cache: false)
|
||||
target.use_memory_cache = false;
|
||||
let disk_iter = XtreamPlaylistIterator::new(
|
||||
XtreamCluster::Live,
|
||||
&app_config,
|
||||
&playlists,
|
||||
&target,
|
||||
None,
|
||||
&ProxyUserCredentials::default(),
|
||||
)
|
||||
.await
|
||||
.expect("disk iterator opens");
|
||||
let disk_items: Vec<(XtreamPlaylistItem, bool)> =
|
||||
disk_iter.collect::<Vec<_>>().await.into_iter().map(Result::unwrap).collect();
|
||||
|
||||
// 2. Read via memory path (use_memory_cache: true)
|
||||
target.use_memory_cache = true;
|
||||
let mem_iter = XtreamPlaylistIterator::new(
|
||||
XtreamCluster::Live,
|
||||
&app_config,
|
||||
&playlists,
|
||||
&target,
|
||||
None,
|
||||
&ProxyUserCredentials::default(),
|
||||
)
|
||||
.await
|
||||
.expect("memory iterator opens");
|
||||
let mem_items: Vec<(XtreamPlaylistItem, bool)> =
|
||||
mem_iter.collect::<Vec<_>>().await.into_iter().map(Result::unwrap).collect();
|
||||
|
||||
assert_eq!(disk_items.len(), 3);
|
||||
assert_eq!(mem_items.len(), 3);
|
||||
assert_eq!(
|
||||
disk_items
|
||||
.iter()
|
||||
.map(|(item, has_next)| (item.virtual_id.get(), item.name.as_ref(), *has_next))
|
||||
.collect::<Vec<_>>(),
|
||||
mem_items
|
||||
.iter()
|
||||
.map(|(item, has_next)| (item.virtual_id.get(), item.name.as_ref(), *has_next))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
mem_items.iter().map(|(item, has_next)| (item.virtual_id.get(), *has_next)).collect::<Vec<_>>(),
|
||||
vec![(2, true), (3, true), (1, false)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,104 +78,66 @@ async fn xtream_read_item_for_stream_id(
|
||||
.map_err(|err| Error::other(format!("Query task failed for {stream_id} in {cluster}: {err}")))?
|
||||
}
|
||||
|
||||
async fn xtream_read_series_item_for_stream_id(
|
||||
cfg: &AppConfig,
|
||||
stream_id: u32,
|
||||
storage_path: &Path,
|
||||
) -> Result<XtreamPlaylistItem, Error> {
|
||||
let xtream_path = xtream_get_file_path(storage_path, XtreamCluster::Series);
|
||||
let file_lock = cfg.file_locks.read_lock(&xtream_path).await;
|
||||
let xtream_path_clone = xtream_path.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<XtreamPlaylistItem, Error> {
|
||||
let _guard = file_lock;
|
||||
let mut query = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path_clone)?;
|
||||
match query.query_zero_copy(&stream_id) {
|
||||
Ok(Some(item)) => Ok(item),
|
||||
Ok(None) => Err(Error::new(ErrorKind::NotFound, format!("Item {stream_id} not found in series"))),
|
||||
Err(err) => Err(Error::other(format!("Query failed for {stream_id} in series: {err}"))),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("Query task failed for {stream_id} in series: {err}")))?
|
||||
}
|
||||
|
||||
async fn xtream_get_item_for_stream_id_from_memory(
|
||||
virtual_id: u32,
|
||||
playlists: &PlaylistStorageState,
|
||||
target: &ConfigTarget,
|
||||
xtream_cluster: Option<XtreamCluster>,
|
||||
) -> Result<Option<(XtreamPlaylistItem, VirtualIdRecord)>, Error> {
|
||||
if let Some(playlist) = playlists.data.read().await.get(target.name.as_str()) {
|
||||
return match (playlist.xtream.as_ref(), playlist.id_mapping.as_ref()) {
|
||||
(Some(xtream_storage), Some(id_mapping)) => {
|
||||
let mapping = id_mapping
|
||||
.query(&VirtualId::new(virtual_id))
|
||||
.ok_or_else(|| {
|
||||
string_to_io_error(format!(
|
||||
"Could not find mapping for target {} and id {}",
|
||||
target.name, virtual_id
|
||||
))
|
||||
})?
|
||||
.clone();
|
||||
let result = match mapping.item_type {
|
||||
PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo => Ok(xtream_storage
|
||||
.series
|
||||
.query(&mapping.virtual_id.get())
|
||||
.ok_or_else(|| string_to_io_error(format!("Failed to read xtream item for id {virtual_id}")))?
|
||||
.clone()),
|
||||
PlaylistItemType::Series | PlaylistItemType::LocalSeries => {
|
||||
log::debug!("In-memory series item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}", virtual_id, mapping.parent_virtual_id, mapping.provider_id);
|
||||
) -> (Option<XtreamPlaylistItem>, Option<VirtualIdRecord>) {
|
||||
let storage = playlists.data.read().await;
|
||||
let Some(playlist) = storage.get(target.name.as_str()) else {
|
||||
return (None, None);
|
||||
};
|
||||
let Some(mapping) =
|
||||
playlist.id_mapping.as_ref().and_then(|id_mapping| id_mapping.query(&VirtualId::new(virtual_id)).cloned())
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
let Some(xtream_storage) = playlist.xtream.as_ref() else {
|
||||
return (None, Some(mapping));
|
||||
};
|
||||
|
||||
if let Some(item) = xtream_storage.series.query(&virtual_id) {
|
||||
Ok(item.clone())
|
||||
} else if let Some(item) = xtream_storage.series.query(&mapping.parent_virtual_id.get()) {
|
||||
let mut xc_item = item.clone();
|
||||
xc_item.provider_id = mapping.provider_id;
|
||||
xc_item.item_type = PlaylistItemType::Series;
|
||||
xc_item.virtual_id = mapping.virtual_id;
|
||||
Ok(xc_item)
|
||||
} else {
|
||||
Err(string_to_io_error(format!("Failed to read xtream item for id {virtual_id}")))
|
||||
}
|
||||
}
|
||||
PlaylistItemType::Catchup => {
|
||||
log::debug!("In-memory catchup item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}", virtual_id, mapping.parent_virtual_id, mapping.provider_id);
|
||||
let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type);
|
||||
let item = match cluster {
|
||||
XtreamCluster::Live => xtream_storage.live.query(&mapping.parent_virtual_id.get()),
|
||||
XtreamCluster::Video => xtream_storage.vod.query(&mapping.parent_virtual_id.get()),
|
||||
XtreamCluster::Series => xtream_storage.series.query(&mapping.parent_virtual_id.get()),
|
||||
};
|
||||
let result: Option<XtreamPlaylistItem> = match mapping.item_type {
|
||||
PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo => None,
|
||||
PlaylistItemType::Series | PlaylistItemType::LocalSeries => {
|
||||
log::debug!(
|
||||
"In-memory series item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}",
|
||||
virtual_id,
|
||||
mapping.parent_virtual_id,
|
||||
mapping.provider_id
|
||||
);
|
||||
xtream_storage.series.query(&virtual_id).cloned()
|
||||
}
|
||||
PlaylistItemType::Catchup => {
|
||||
log::debug!(
|
||||
"In-memory catchup item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}",
|
||||
virtual_id,
|
||||
mapping.parent_virtual_id,
|
||||
mapping.provider_id
|
||||
);
|
||||
let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type);
|
||||
let item = xtream_storage.get_cluster(cluster).0.query(&mapping.parent_virtual_id.get());
|
||||
|
||||
if let Some(pl_item) = item {
|
||||
let mut xc_item = pl_item.clone();
|
||||
xc_item.provider_id = mapping.provider_id;
|
||||
xc_item.item_type = PlaylistItemType::Catchup;
|
||||
xc_item.virtual_id = mapping.virtual_id;
|
||||
Ok(xc_item)
|
||||
} else {
|
||||
Err(string_to_io_error(format!("Failed to read xtream item for id {virtual_id}")))
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type);
|
||||
Ok((match cluster {
|
||||
XtreamCluster::Live => xtream_storage.live.query(&virtual_id),
|
||||
XtreamCluster::Video => xtream_storage.vod.query(&virtual_id),
|
||||
XtreamCluster::Series => xtream_storage.series.query(&virtual_id),
|
||||
})
|
||||
.ok_or_else(|| string_to_io_error(format!("Failed to read xtream item for id {virtual_id}")))?
|
||||
.clone())
|
||||
}
|
||||
};
|
||||
item.map(|pl_item| {
|
||||
let mut xc_item = pl_item.clone();
|
||||
xc_item.provider_id = mapping.provider_id;
|
||||
xc_item.item_type = PlaylistItemType::Catchup;
|
||||
xc_item.virtual_id = mapping.virtual_id;
|
||||
xc_item
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type);
|
||||
xtream_storage
|
||||
.get_cluster(cluster)
|
||||
.0
|
||||
.query(&virtual_id)
|
||||
.filter(|item| cluster != XtreamCluster::Series || !item.item_type.is_series_info())
|
||||
.cloned()
|
||||
}
|
||||
};
|
||||
|
||||
result.map(|xpli| Some((xpli, mapping)))
|
||||
}
|
||||
_ => Ok(None),
|
||||
};
|
||||
}
|
||||
//Err(string_to_io_error(format!("Failed to read xtream item for id {virtual_id}. No entry found.")))
|
||||
Ok(None)
|
||||
(result, Some(mapping))
|
||||
}
|
||||
|
||||
pub async fn xtream_get_item_for_stream_id(
|
||||
@@ -185,117 +147,165 @@ pub async fn xtream_get_item_for_stream_id(
|
||||
target: &ConfigTarget,
|
||||
xtream_cluster: Option<XtreamCluster>,
|
||||
) -> Result<XtreamPlaylistItem, Error> {
|
||||
if target.use_memory_cache {
|
||||
if let Ok(Some((playlist_item, _virtual_record))) =
|
||||
xtream_get_item_for_stream_id_from_memory(virtual_id, playlists, target, xtream_cluster).await
|
||||
{
|
||||
return Ok(playlist_item);
|
||||
let mapping_record = if target.use_memory_cache {
|
||||
let (playlist_item, virtual_record) =
|
||||
xtream_get_item_for_stream_id_from_memory(virtual_id, playlists, target, xtream_cluster).await;
|
||||
if let Some(item) = playlist_item {
|
||||
return Ok(item);
|
||||
}
|
||||
// fall through to disk lookup on cache miss
|
||||
}
|
||||
virtual_record
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let config = app_config.config.load();
|
||||
let target_path = get_target_storage_path(&config, target.name.as_str())
|
||||
.ok_or_else(|| string_to_io_error(format!("Could not find path for target {}", target.name)))?;
|
||||
let storage_path = xtream_get_storage_path(&config, target.name.as_str())
|
||||
.ok_or_else(|| string_to_io_error(format!("Could not find path for target {} xtream output", target.name)))?;
|
||||
{
|
||||
let result = if let Some(cluster) = xtream_cluster {
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster).await
|
||||
} else {
|
||||
let target_id_mapping_file = get_target_id_mapping_file(&target_path);
|
||||
let target_name = target.name.clone();
|
||||
let file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await;
|
||||
let target_id_mapping_file_clone = target_id_mapping_file.clone();
|
||||
let mapping = tokio::task::spawn_blocking(move || -> Result<VirtualIdRecord, Error> {
|
||||
let _guard = file_lock;
|
||||
let mut target_id_mapping =
|
||||
BPlusTreeQuery::<u32, VirtualIdRecord>::try_new(&target_id_mapping_file_clone).map_err(|err| {
|
||||
string_to_io_error(format!("Could not load id mapping for target {target_name} err:{err}"))
|
||||
})?;
|
||||
match target_id_mapping.query_zero_copy(&virtual_id) {
|
||||
Ok(Some(record)) => Ok(record),
|
||||
Ok(None) => Err(string_to_io_error(format!(
|
||||
"Could not find mapping for target {target_name} and id {virtual_id}"
|
||||
))),
|
||||
Err(err) => Err(string_to_io_error(format!("Query failed for id {virtual_id}: {err}"))),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|err| string_to_io_error(format!("Mapping query task failed for id {virtual_id}: {err}")))??;
|
||||
match mapping.item_type {
|
||||
PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo => {
|
||||
xtream_read_series_item_for_stream_id(app_config, virtual_id, &storage_path).await
|
||||
}
|
||||
PlaylistItemType::Series | PlaylistItemType::LocalSeries => {
|
||||
log::debug!(
|
||||
"Disk series item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}",
|
||||
virtual_id,
|
||||
mapping.parent_virtual_id,
|
||||
mapping.provider_id
|
||||
);
|
||||
xtream_get_item_for_stream_id_from_disk(virtual_id, app_config, target, xtream_cluster, mapping_record).await
|
||||
}
|
||||
|
||||
if let Ok(episode) =
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series)
|
||||
.await
|
||||
{
|
||||
return Ok(episode);
|
||||
}
|
||||
async fn read_virtual_id_record(
|
||||
app_config: &Arc<AppConfig>,
|
||||
target_path: &Path,
|
||||
target_name: &str,
|
||||
virtual_id: u32,
|
||||
) -> Result<VirtualIdRecord, Error> {
|
||||
let target_id_mapping_file = get_target_id_mapping_file(target_path);
|
||||
let target_name = target_name.to_string();
|
||||
let file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await;
|
||||
let target_id_mapping_file_clone = target_id_mapping_file.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<VirtualIdRecord, Error> {
|
||||
let _guard = file_lock;
|
||||
let mut target_id_mapping = BPlusTreeQuery::<u32, VirtualIdRecord>::try_new(&target_id_mapping_file_clone)
|
||||
.map_err(|err| {
|
||||
string_to_io_error(format!("Could not load id mapping for target {target_name} err:{err}"))
|
||||
})?;
|
||||
match target_id_mapping.query_zero_copy(&virtual_id) {
|
||||
Ok(Some(record)) => Ok(record),
|
||||
Ok(None) => {
|
||||
Err(string_to_io_error(format!("Could not find mapping for target {target_name} and id {virtual_id}")))
|
||||
}
|
||||
Err(err) => Err(string_to_io_error(format!("Query failed for id {virtual_id}: {err}"))),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|err| string_to_io_error(format!("Mapping query task failed for id {virtual_id}: {err}")))?
|
||||
}
|
||||
|
||||
if let Ok(mut item) = xtream_read_series_item_for_stream_id(
|
||||
app_config,
|
||||
mapping.parent_virtual_id.get(),
|
||||
&storage_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
item.provider_id = mapping.provider_id;
|
||||
item.item_type = PlaylistItemType::Series;
|
||||
item.virtual_id = mapping.virtual_id;
|
||||
return Ok(item);
|
||||
}
|
||||
async fn resolve_mapping_item_from_disk(
|
||||
app_config: &Arc<AppConfig>,
|
||||
storage_path: &Path,
|
||||
virtual_id: u32,
|
||||
mapping: &VirtualIdRecord,
|
||||
xtream_cluster: Option<XtreamCluster>,
|
||||
direct_attempted: bool,
|
||||
) -> Result<XtreamPlaylistItem, Error> {
|
||||
match mapping.item_type {
|
||||
PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo => {
|
||||
if direct_attempted && xtream_cluster == Some(XtreamCluster::Series) {
|
||||
return Err(Error::new(
|
||||
ErrorKind::NotFound,
|
||||
format!("Item {virtual_id} not found in {}", XtreamCluster::Series),
|
||||
));
|
||||
}
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, storage_path, XtreamCluster::Series).await
|
||||
}
|
||||
PlaylistItemType::Series | PlaylistItemType::LocalSeries => {
|
||||
log::debug!(
|
||||
"Disk series item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}",
|
||||
virtual_id,
|
||||
mapping.parent_virtual_id,
|
||||
mapping.provider_id
|
||||
);
|
||||
|
||||
return Err(Error::other(format!("Failed to find episode item with virtual-id {virtual_id}")));
|
||||
}
|
||||
PlaylistItemType::Catchup => {
|
||||
log::debug!(
|
||||
"Disk catchup item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}",
|
||||
virtual_id,
|
||||
mapping.parent_virtual_id,
|
||||
mapping.provider_id
|
||||
);
|
||||
let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type);
|
||||
let mut item = xtream_read_item_for_stream_id(
|
||||
app_config,
|
||||
mapping.parent_virtual_id.get(),
|
||||
&storage_path,
|
||||
cluster,
|
||||
)
|
||||
.await?;
|
||||
item.provider_id = mapping.provider_id;
|
||||
item.item_type = PlaylistItemType::Catchup;
|
||||
item.virtual_id = mapping.virtual_id;
|
||||
Ok(item)
|
||||
}
|
||||
_ => {
|
||||
let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type);
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster).await
|
||||
if !(direct_attempted && xtream_cluster == Some(XtreamCluster::Series)) {
|
||||
if let Ok(episode) =
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, storage_path, XtreamCluster::Series).await
|
||||
{
|
||||
return Ok(episode);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
result
|
||||
if let Ok(mut item) = xtream_read_item_for_stream_id(
|
||||
app_config,
|
||||
mapping.parent_virtual_id.get(),
|
||||
storage_path,
|
||||
XtreamCluster::Series,
|
||||
)
|
||||
.await
|
||||
{
|
||||
item.provider_id = mapping.provider_id;
|
||||
item.item_type = mapping.item_type;
|
||||
item.virtual_id = mapping.virtual_id;
|
||||
return Ok(item);
|
||||
}
|
||||
|
||||
Err(Error::other(format!("Failed to find episode item with virtual-id {virtual_id}")))
|
||||
}
|
||||
PlaylistItemType::Catchup => {
|
||||
log::debug!(
|
||||
"Disk catchup item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}",
|
||||
virtual_id,
|
||||
mapping.parent_virtual_id,
|
||||
mapping.provider_id
|
||||
);
|
||||
let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type);
|
||||
let mut item =
|
||||
xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id.get(), storage_path, cluster)
|
||||
.await?;
|
||||
item.provider_id = mapping.provider_id;
|
||||
item.item_type = PlaylistItemType::Catchup;
|
||||
item.virtual_id = mapping.virtual_id;
|
||||
Ok(item)
|
||||
}
|
||||
_ => {
|
||||
let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type);
|
||||
if direct_attempted && xtream_cluster == Some(cluster) {
|
||||
return Err(Error::new(ErrorKind::NotFound, format!("Item {virtual_id} not found in {cluster}")));
|
||||
}
|
||||
xtream_read_item_for_stream_id(app_config, virtual_id, storage_path, cluster).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn xtream_get_item_for_stream_id_from_disk(
|
||||
virtual_id: u32,
|
||||
app_config: &Arc<AppConfig>,
|
||||
target: &ConfigTarget,
|
||||
xtream_cluster: Option<XtreamCluster>,
|
||||
mapping_record: Option<VirtualIdRecord>,
|
||||
) -> Result<XtreamPlaylistItem, Error> {
|
||||
let config = app_config.config.load();
|
||||
let storage_path = xtream_get_storage_path(&config, target.name.as_str())
|
||||
.ok_or_else(|| string_to_io_error(format!("Could not find path for target {} xtream output", target.name)))?;
|
||||
|
||||
let direct_attempted = if let Some(cluster) = xtream_cluster {
|
||||
if let Ok(item) = xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster).await {
|
||||
return Ok(item);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let mapping = if let Some(record) = mapping_record {
|
||||
record
|
||||
} else {
|
||||
let target_path = get_target_storage_path(&config, target.name.as_str())
|
||||
.ok_or_else(|| string_to_io_error(format!("Could not find path for target {}", target.name)))?;
|
||||
read_virtual_id_record(app_config, &target_path, &target.name, virtual_id).await?
|
||||
};
|
||||
|
||||
resolve_mapping_item_from_disk(app_config, &storage_path, virtual_id, &mapping, xtream_cluster, direct_attempted)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn xtream_load_rewrite_playlist(
|
||||
cluster: XtreamCluster,
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlists: &PlaylistStorageState,
|
||||
target: &ConfigTarget,
|
||||
category_id: Option<u32>,
|
||||
user: &ProxyUserCredentials,
|
||||
) -> Result<XtreamPlaylistJsonIterator, TuliproxError> {
|
||||
XtreamPlaylistJsonIterator::new(cluster, app_config, target, category_id, user).await
|
||||
XtreamPlaylistJsonIterator::new(cluster, app_config, playlists, target, category_id, user).await
|
||||
}
|
||||
|
||||
pub async fn iter_raw_xtream_target_playlist(
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::{
|
||||
#[cfg(windows)]
|
||||
use super::{encode_windows_path, sync_published_file_parent};
|
||||
use crate::{
|
||||
bplustree::{get_file_path_for_db_index, BPlusTree, BPlusTreeStagingArtifacts, BPlusTreeUpdate},
|
||||
bplustree::{get_file_path_for_db_index, BPlusTree, BPlusTreeStagingArtifacts, BPlusTreeUpdate, IndexSnapshot},
|
||||
error_macros::cant_write_result,
|
||||
playlist_backend::PlaylistKey,
|
||||
storage_const,
|
||||
@@ -23,7 +23,7 @@ use serde_json::Value;
|
||||
use shared::{
|
||||
error::TuliproxError,
|
||||
model::{ClusterFlags, PlaylistGroup, XtreamCluster, XtreamPlaylistItem},
|
||||
utils::{get_u32_from_serde_value, Internable},
|
||||
utils::{get_u32_from_serde_value, sanitize_sensitive_info, Internable},
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use std::fs;
|
||||
@@ -485,39 +485,7 @@ pub async fn write_playlist_item_update(
|
||||
target_name: &str,
|
||||
pli: &XtreamPlaylistItem,
|
||||
) -> Result<(), TuliproxError> {
|
||||
let storage_path = {
|
||||
let config = app_config.config.load();
|
||||
ensure_xtream_storage_path(&config, target_name).await?
|
||||
};
|
||||
let xtream_path = xtream_get_file_path(&storage_path, pli.xtream_cluster);
|
||||
|
||||
if !file_exists_async(&xtream_path).await {
|
||||
return Err(TuliproxError::RepositoryXtream(format!(
|
||||
"BPlusTree file not found for update {}",
|
||||
xtream_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Prepare encoded payload before opening the writer lock.
|
||||
let prepared_items =
|
||||
BPlusTreeUpdate::<u32, XtreamPlaylistItem>::prepare_upsert_batch(&[(&pli.virtual_id.get(), pli)])
|
||||
.map_err(|e| TuliproxError::RepositoryXtream(format!("Failed to serialize value: {e}")))?;
|
||||
|
||||
// Keep FileLockManager lock for cross-operation coordination (e.g. swap + update).
|
||||
let file_lock = app_config.file_locks.write_lock(&xtream_path).await;
|
||||
|
||||
let xtream_path_clone = xtream_path.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<(), std::io::Error> {
|
||||
let _guard = file_lock;
|
||||
let mut tree = BPlusTreeUpdate::<u32, XtreamPlaylistItem>::try_new_with_backoff(&xtream_path_clone)?;
|
||||
tree.upsert_batch_encoded(prepared_items)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| TuliproxError::RepositoryXtream(format!("Blocking task failed: {e}")))?
|
||||
.map_err(|err| cant_write_result!(RepositoryXtream, "xtream", &xtream_path, err))?;
|
||||
|
||||
Ok(())
|
||||
write_playlist_batch_item_upsert(app_config, target_name, pli.xtream_cluster, std::slice::from_ref(pli)).await
|
||||
}
|
||||
|
||||
pub async fn write_playlist_batch_item_upsert(
|
||||
@@ -549,6 +517,8 @@ pub async fn write_playlist_batch_item_upsert(
|
||||
let prepared_items = BPlusTreeUpdate::<u32, XtreamPlaylistItem>::prepare_upsert_batch(&batch_refs)
|
||||
.map_err(|e| TuliproxError::RepositoryXtream(format!("Failed to serialize value: {e}")))?;
|
||||
|
||||
let items_info: Vec<(u32, u32)> = pli_list.iter().map(|pli| (pli.source_ordinal, pli.virtual_id.get())).collect();
|
||||
|
||||
// Keep FileLockManager lock for cross-operation coordination (e.g. swap + update).
|
||||
let file_lock = app_config.file_locks.write_lock(&xtream_path).await;
|
||||
|
||||
@@ -556,7 +526,30 @@ pub async fn write_playlist_batch_item_upsert(
|
||||
tokio::task::spawn_blocking(move || -> Result<(), std::io::Error> {
|
||||
let _guard = file_lock;
|
||||
let mut tree = BPlusTreeUpdate::<u32, XtreamPlaylistItem>::try_new_with_backoff(&xtream_path_clone)?;
|
||||
let index_path = get_file_path_for_db_index(&xtream_path_clone);
|
||||
let snapshot = IndexSnapshot::<u32, u32>::read(&index_path).ok().flatten();
|
||||
// Keys that are new or moved to another `source_ordinal`; only needed when an index is kept.
|
||||
let mut changed = Vec::new();
|
||||
if snapshot.is_some() {
|
||||
for (source_ordinal, virtual_id) in items_info {
|
||||
let moved = tree
|
||||
.query(&virtual_id)
|
||||
.map_err(std::io::Error::other)?
|
||||
.is_none_or(|old_item| old_item.source_ordinal != source_ordinal);
|
||||
if moved {
|
||||
changed.push((source_ordinal, virtual_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
tree.upsert_batch_encoded(prepared_items)?;
|
||||
if let Some(snapshot) = snapshot {
|
||||
if let Err(err) = snapshot.write_merged(&index_path, &changed) {
|
||||
warn!(
|
||||
"Failed to write merged index snapshot for {}: {err}",
|
||||
sanitize_sensitive_info(&index_path.to_string_lossy())
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -451,8 +451,13 @@ reverse_proxy:
|
||||
|
||||
## 5. Resource Retries (`resource_retry`)
|
||||
|
||||
This block defines the retry behavior when Tuliprox proxies static resources (logos, EPG images).
|
||||
It ensures transient network issues or temporary provider timeouts don't result in broken images for your clients.
|
||||
This block defines the shared retry behavior for outbound requests that use the standard retry policy.
|
||||
It ensures transient network issues or temporary provider timeouts do not fail a request on the first error.
|
||||
|
||||
Image proxy fetches (logos, covers, EPG icons on the Web UI and player resource routes) do not use these same-URL
|
||||
retries: they make one attempt per URL, fail after 15 seconds without a response, and run at most 6 concurrent
|
||||
upstream fetches per host. Provider URL failover still applies. A failing image server therefore cannot hold a
|
||||
client connection for minutes or receive a retry burst.
|
||||
|
||||
```yaml
|
||||
reverse_proxy:
|
||||
|
||||
@@ -1562,7 +1562,7 @@ sources:
|
||||
| `curation` | Object | No | | Optional target-wide catalog policy with Trakt and/or TMDB Trending sources. Mutually exclusive with legacy `output[].trakt`; see [target-wide curation](#target-wide-curation-curation). |
|
||||
| `favourites` | List | No | | Duplicates final transformed channels into dedicated favorite groups after processing is complete. This adds curated views without changing the original group structure. |
|
||||
| `watch` | List | No | | Watch entries for this target: channel changes in matching groups and/or matching groups that appear or disappear. Changes are reported as Messaging events; see [Watch](#329-watch-watch). |
|
||||
| `use_memory_cache` | Bool | No | `false` | If enabled, the final compiled playlist is cached in RAM. This reduces disk access and improves delivery speed, especially for M3U downloads, but increases memory consumption. |
|
||||
| `use_memory_cache` | Bool | No | `false` | If enabled, the final playlist is cached in RAM to speed up M3U downloads and Xtream lists (in sorted-index order). Series season and episode details remain on disk to keep RAM usage lean. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1202,7 +1202,7 @@ pub fn EpgView() -> Html {
|
||||
style={channel_style.clone()}>
|
||||
<div class="tp__epg__channel-icon">
|
||||
{ if let Some(icon) = &ch.icon {
|
||||
html! { <img src={icon.to_string()} alt={ch.title.to_string()} /> }
|
||||
html! { <img src={icon.to_string()} alt={ch.title.to_string()} loading="lazy" decoding="async" /> }
|
||||
} else { html!{} }
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -84,10 +84,7 @@ impl Leaf {
|
||||
let is_match = match item_type {
|
||||
PlaylistItemType::Live => actual.is_live(),
|
||||
PlaylistItemType::Video => actual.is_video(),
|
||||
PlaylistItemType::Series => {
|
||||
actual.is_series()
|
||||
|| matches!(actual, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|
||||
}
|
||||
PlaylistItemType::Series => actual.is_series() || actual.is_series_info(),
|
||||
_ => actual == *item_type,
|
||||
};
|
||||
if log_enabled!(Level::Trace) {
|
||||
|
||||
@@ -38,5 +38,6 @@ pub use item::{PlaylistEntry, PlaylistItem};
|
||||
pub use kind::{PlaylistItemType, XtreamCluster};
|
||||
pub use m3u::M3uPlaylistItem;
|
||||
pub use xtream::{
|
||||
ResourceOutputPolicy, XtreamMappingFlags, XtreamMappingFlagsSet, XtreamMappingOptions, XtreamPlaylistItem,
|
||||
ResourceOutputPolicy, SeriesListProjection, XtreamMappingFlags, XtreamMappingFlagsSet, XtreamMappingOptions,
|
||||
XtreamPlaylistItem,
|
||||
};
|
||||
|
||||
@@ -152,6 +152,11 @@ impl PlaylistItemType {
|
||||
/// True for concrete series item types (`Series` or `LocalSeries`); excludes the `SeriesInfo` containers.
|
||||
pub fn is_series(&self) -> bool { matches!(self, PlaylistItemType::Series | PlaylistItemType::LocalSeries) }
|
||||
|
||||
/// True for series info container types (`SeriesInfo` or `LocalSeriesInfo`).
|
||||
pub fn is_series_info(&self) -> bool {
|
||||
matches!(self, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|
||||
}
|
||||
|
||||
/// Controls address tracking only.
|
||||
/// Do not use this to decide whether a playback request should use session-based admission
|
||||
/// or whether a logical playback must stay on the same provider account.
|
||||
|
||||
@@ -19,6 +19,16 @@ fn cluster_is_the_single_source_of_truth_for_every_item_type() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_series_info_matches_only_series_info_types() {
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
for item_type in PlaylistItemType::iter() {
|
||||
let expected = matches!(item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo);
|
||||
assert_eq!(item_type.is_series_info(), expected, "{item_type:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_stalker_uuid_is_seeded_with_input_name_not_category() {
|
||||
let mut item = StalkerPlaylistItem {
|
||||
|
||||
@@ -209,3 +209,267 @@ fn get_resource_url_does_not_trust_absolute_urls_containing_internal_thumbnail_p
|
||||
concat_path(&options.base_url, &seal_web_ui_resource_url(&options.encrypt_secret, resource_url)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_list_projection_skips_details_on_rmp_decode() {
|
||||
use crate::model::{
|
||||
SeriesListProjection, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties,
|
||||
SeriesStreamDetailSeasonProperties, SeriesStreamProperties,
|
||||
};
|
||||
|
||||
let details = SeriesStreamDetailProperties {
|
||||
year: Some(2024),
|
||||
seasons: Some(vec![SeriesStreamDetailSeasonProperties {
|
||||
name: "Season 1".intern(),
|
||||
season_number: 1,
|
||||
episode_count: 8,
|
||||
overview: Some("Overview".intern()),
|
||||
air_date: Some("2024-01-01".intern()),
|
||||
cover: Some("http://example.test/s1.jpg".intern()),
|
||||
cover_tmdb: None,
|
||||
cover_big: None,
|
||||
duration: None,
|
||||
}]),
|
||||
episodes: Some(vec![SeriesStreamDetailEpisodeProperties {
|
||||
id: 101,
|
||||
episode_num: 1,
|
||||
season: 1,
|
||||
title: "Pilot".intern(),
|
||||
container_extension: "mkv".intern(),
|
||||
custom_sid: None,
|
||||
added: "1700000000".intern(),
|
||||
direct_source: "".intern(),
|
||||
tmdb: Some(1234),
|
||||
release_date: "2024-01-01".intern(),
|
||||
series_release_date: None,
|
||||
plot: Some("Plot".intern()),
|
||||
crew: None,
|
||||
duration_secs: 3600,
|
||||
duration: "60".intern(),
|
||||
movie_image: "http://example.test/e1.jpg".intern(),
|
||||
bitrate: 4000,
|
||||
rating: Some(8.5),
|
||||
video: None,
|
||||
audio: None,
|
||||
}]),
|
||||
};
|
||||
let series_props = SeriesStreamProperties {
|
||||
name: "Test Series".intern(),
|
||||
category_id: 10,
|
||||
series_id: 42,
|
||||
cast: "Cast".intern(),
|
||||
cover: "http://example.test/cover.jpg".intern(),
|
||||
director: "Director".intern(),
|
||||
genre: Some("Drama".intern()),
|
||||
plot: Some("Series Plot".intern()),
|
||||
rating: 9.0,
|
||||
details: Some(details),
|
||||
..Default::default()
|
||||
};
|
||||
let item = XtreamPlaylistItem {
|
||||
virtual_id: VirtualId::new(42),
|
||||
provider_id: 42,
|
||||
name: "Test Series".intern(),
|
||||
logo: "http://example.test/logo.jpg".intern(),
|
||||
logo_small: "".intern(),
|
||||
group: "Drama".intern(),
|
||||
title: "Test Series".intern(),
|
||||
parent_code: "".intern(),
|
||||
rec: "".intern(),
|
||||
url: "http://example.test/stream".intern(),
|
||||
epg_channel_id: None,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
additional_properties: Some(StreamProperties::Series(Box::new(series_props))),
|
||||
item_type: PlaylistItemType::SeriesInfo,
|
||||
category_id: 10,
|
||||
input_name: "test-input".intern(),
|
||||
channel_no: 1,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "42".intern(),
|
||||
upstream_user_agent: None,
|
||||
};
|
||||
|
||||
let encoded = rmp_serde::to_vec(&item).expect("rmp encode succeeds");
|
||||
|
||||
// Standard decode preserves details
|
||||
let decoded_full: XtreamPlaylistItem = rmp_serde::from_slice(&encoded).expect("rmp decode succeeds");
|
||||
assert!(decoded_full.has_details());
|
||||
let full_series = match decoded_full.additional_properties.as_ref() {
|
||||
Some(StreamProperties::Series(s)) => s,
|
||||
_ => panic!("expected series properties"),
|
||||
};
|
||||
assert!(full_series.details.is_some());
|
||||
|
||||
// Projection decode drops details
|
||||
let projected: SeriesListProjection = rmp_serde::from_slice(&encoded).expect("projection decode succeeds");
|
||||
let decoded_projected = projected.0;
|
||||
assert!(!decoded_projected.has_details());
|
||||
let projected_series = match decoded_projected.additional_properties.as_ref() {
|
||||
Some(StreamProperties::Series(s)) => s,
|
||||
_ => panic!("expected series properties"),
|
||||
};
|
||||
assert!(projected_series.details.is_none());
|
||||
|
||||
// All other series fields are equal
|
||||
let mut expected_series = full_series.clone();
|
||||
expected_series.details = None;
|
||||
assert_eq!(projected_series, &expected_series);
|
||||
|
||||
// Header fields are equal
|
||||
assert_eq!(decoded_projected.virtual_id, decoded_full.virtual_id);
|
||||
assert_eq!(decoded_projected.provider_id, decoded_full.provider_id);
|
||||
assert_eq!(decoded_projected.name, decoded_full.name);
|
||||
assert_eq!(decoded_projected.logo, decoded_full.logo);
|
||||
assert_eq!(decoded_projected.group, decoded_full.group);
|
||||
assert_eq!(decoded_projected.title, decoded_full.title);
|
||||
assert_eq!(decoded_projected.url, decoded_full.url);
|
||||
assert_eq!(decoded_projected.item_type, decoded_full.item_type);
|
||||
assert_eq!(decoded_projected.category_id, decoded_full.category_id);
|
||||
assert_eq!(decoded_projected.input_name, decoded_full.input_name);
|
||||
assert_eq!(decoded_projected.channel_no, decoded_full.channel_no);
|
||||
assert_eq!(decoded_projected.input_stream_id, decoded_full.input_stream_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_get_series_json_contains_no_seasons_or_episodes() {
|
||||
use crate::model::{
|
||||
PlaylistItemTypeSet, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties,
|
||||
SeriesStreamDetailSeasonProperties, SeriesStreamProperties, XtreamMappingFlags, XtreamMappingOptions,
|
||||
};
|
||||
|
||||
let details = SeriesStreamDetailProperties {
|
||||
year: Some(2024),
|
||||
seasons: Some(vec![SeriesStreamDetailSeasonProperties {
|
||||
name: "Season 1".intern(),
|
||||
season_number: 1,
|
||||
episode_count: 8,
|
||||
overview: Some("Overview".intern()),
|
||||
air_date: Some("2024-01-01".intern()),
|
||||
cover: Some("http://example.test/s1.jpg".intern()),
|
||||
cover_tmdb: None,
|
||||
cover_big: None,
|
||||
duration: None,
|
||||
}]),
|
||||
episodes: Some(vec![SeriesStreamDetailEpisodeProperties {
|
||||
id: 101,
|
||||
episode_num: 1,
|
||||
season: 1,
|
||||
title: "Pilot".intern(),
|
||||
container_extension: "mkv".intern(),
|
||||
custom_sid: None,
|
||||
added: "1700000000".intern(),
|
||||
direct_source: "".intern(),
|
||||
tmdb: Some(1234),
|
||||
release_date: "2024-01-01".intern(),
|
||||
series_release_date: None,
|
||||
plot: Some("Plot".intern()),
|
||||
crew: None,
|
||||
duration_secs: 3600,
|
||||
duration: "60".intern(),
|
||||
movie_image: "http://example.test/e1.jpg".intern(),
|
||||
bitrate: 4000,
|
||||
rating: Some(8.5),
|
||||
video: None,
|
||||
audio: None,
|
||||
}]),
|
||||
};
|
||||
let series_props = SeriesStreamProperties {
|
||||
name: "Test Series".intern(),
|
||||
category_id: 10,
|
||||
series_id: 42,
|
||||
cast: "Cast".intern(),
|
||||
cover: "http://example.test/cover.jpg".intern(),
|
||||
director: "Director".intern(),
|
||||
genre: Some("Drama".intern()),
|
||||
plot: Some("Series Plot".intern()),
|
||||
rating: 9.0,
|
||||
details: Some(details),
|
||||
..Default::default()
|
||||
};
|
||||
let item = XtreamPlaylistItem {
|
||||
virtual_id: VirtualId::new(42),
|
||||
provider_id: 42,
|
||||
name: "Test Series".intern(),
|
||||
logo: "http://example.test/logo.jpg".intern(),
|
||||
logo_small: "".intern(),
|
||||
group: "Drama".intern(),
|
||||
title: "Test Series".intern(),
|
||||
parent_code: "".intern(),
|
||||
rec: "".intern(),
|
||||
url: "http://example.test/stream".intern(),
|
||||
epg_channel_id: None,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
additional_properties: Some(StreamProperties::Series(Box::new(series_props))),
|
||||
item_type: PlaylistItemType::SeriesInfo,
|
||||
category_id: 10,
|
||||
input_name: "test-input".intern(),
|
||||
channel_no: 1,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "42".intern(),
|
||||
upstream_user_agent: None,
|
||||
};
|
||||
|
||||
let options = XtreamMappingOptions {
|
||||
flags: XtreamMappingFlags::RewriteResourceUrl.into(),
|
||||
force_redirect: None,
|
||||
reverse_item_types: PlaylistItemTypeSet::empty(),
|
||||
resource_proxy_item_types: PlaylistItemTypeSet::empty(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
base_url: "http://proxy.example".to_string(),
|
||||
web_ui_request: false,
|
||||
encrypt_secret: [0u8; 16],
|
||||
resource_host_policies: Arc::default(),
|
||||
};
|
||||
|
||||
let doc = item.to_document(&options);
|
||||
let json = serde_json::to_string(&doc).expect("serialize get_series document");
|
||||
let value: serde_json::Value = serde_json::from_str(&json).expect("parse json");
|
||||
|
||||
assert!(!json.contains("\"seasons\""), "rendered get_series JSON must not contain seasons");
|
||||
assert!(!json.contains("\"episodes\""), "rendered get_series JSON must not contain episodes");
|
||||
let obj = value.as_object().expect("expected json object");
|
||||
assert!(!obj.contains_key("seasons"));
|
||||
assert!(!obj.contains_key("episodes"));
|
||||
assert_eq!(obj.get("name").and_then(serde_json::Value::as_str), Some("Test Series"));
|
||||
assert_eq!(obj.get("series_id").and_then(serde_json::Value::as_u64), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_series_details_removes_details() {
|
||||
use crate::model::{SeriesStreamDetailProperties, SeriesStreamProperties};
|
||||
|
||||
let details = SeriesStreamDetailProperties { year: Some(2024), seasons: None, episodes: None };
|
||||
let series_props =
|
||||
SeriesStreamProperties { name: "Test Series".intern(), details: Some(details), ..Default::default() };
|
||||
let mut item = XtreamPlaylistItem {
|
||||
virtual_id: VirtualId::new(1),
|
||||
provider_id: 1,
|
||||
name: "Test".intern(),
|
||||
logo: "".intern(),
|
||||
logo_small: "".intern(),
|
||||
group: "".intern(),
|
||||
title: "".intern(),
|
||||
parent_code: "".intern(),
|
||||
rec: "".intern(),
|
||||
url: "".intern(),
|
||||
epg_channel_id: None,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
additional_properties: Some(StreamProperties::Series(Box::new(series_props))),
|
||||
item_type: PlaylistItemType::SeriesInfo,
|
||||
category_id: 1,
|
||||
input_name: "test".intern(),
|
||||
channel_no: 1,
|
||||
source_ordinal: 0,
|
||||
input_stream_id: "1".intern(),
|
||||
upstream_user_agent: None,
|
||||
};
|
||||
|
||||
assert!(item.has_details());
|
||||
item.clear_series_details();
|
||||
assert!(!item.has_details());
|
||||
match item.additional_properties {
|
||||
Some(StreamProperties::Series(props)) => assert_eq!(props.details, None),
|
||||
_ => panic!("expected series properties"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ use super::{PlaylistEntry, PlaylistItemType, PlaylistItemTypeSet, XtreamCluster}
|
||||
use crate::{
|
||||
create_bitset,
|
||||
model::{
|
||||
xtream_const, ClusterFlags, CommonPlaylistItem, StreamProperties, UUIDType, VirtualId, XtreamInfoDocument,
|
||||
xtream_const, ClusterFlags, CommonPlaylistItem, SkipSeriesDetailsGuard, StreamProperties, UUIDType, VirtualId,
|
||||
XtreamInfoDocument,
|
||||
},
|
||||
utils::{
|
||||
arc_str_option_serde, arc_str_serde, concat_path, generate_runtime_playlist_uuid, seal_web_ui_resource_url,
|
||||
@@ -258,6 +259,13 @@ impl XtreamPlaylistItem {
|
||||
self.additional_properties.as_ref().is_some_and(super::super::stream_properties::StreamProperties::has_details)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn clear_series_details(&mut self) {
|
||||
if let Some(props) = self.additional_properties.as_mut() {
|
||||
props.clear_series_details();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visit_resource_urls(&self, mut visit: impl FnMut(&str)) {
|
||||
let mut visit_persisted = |value: &str| {
|
||||
if let Some(url) = super::super::persisted_resource_url(value) {
|
||||
@@ -404,3 +412,25 @@ impl PlaylistEntry for XtreamPlaylistItem {
|
||||
impl From<XtreamPlaylistItem> for CommonPlaylistItem {
|
||||
fn from(item: XtreamPlaylistItem) -> Self { item.to_common() }
|
||||
}
|
||||
|
||||
/// Wrapper around `XtreamPlaylistItem` that enables the `skip_series_details`
|
||||
/// thread-local flag during deserialization, discarding season and episode details
|
||||
/// (which are not needed on the `get_series` list path).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SeriesListProjection(pub XtreamPlaylistItem);
|
||||
|
||||
impl<'de> Deserialize<'de> for SeriesListProjection {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let _guard = SkipSeriesDetailsGuard::enter();
|
||||
XtreamPlaylistItem::deserialize(deserializer).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SeriesListProjection> for XtreamPlaylistItem {
|
||||
#[inline]
|
||||
fn from(projection: SeriesListProjection) -> Self { projection.0 }
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ impl StreamProperties {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_series_details(&mut self) {
|
||||
if let StreamProperties::Series(series) = self {
|
||||
series.details = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(&mut self) {
|
||||
match self {
|
||||
StreamProperties::Live(live) => {
|
||||
@@ -333,6 +339,7 @@ mod video;
|
||||
pub use catchup::{CatchupAttribute, CatchupProperties};
|
||||
pub use live::LiveStreamProperties;
|
||||
pub use resources::is_resource_field_name;
|
||||
pub(crate) use series::SkipSeriesDetailsGuard;
|
||||
use series::{first_series_episode_value, non_empty_arc, parse_season_episode_field, parse_season_field};
|
||||
pub use series::{
|
||||
normalize_episode_title, EpisodeStreamProperties, SeriesStreamDetailEpisodeProperties,
|
||||
|
||||
@@ -9,7 +9,45 @@ use crate::{
|
||||
};
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::{cell::Cell, sync::Arc};
|
||||
|
||||
thread_local! {
|
||||
static SKIP_SERIES_DETAILS: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// RAII guard that sets the thread-local `SKIP_SERIES_DETAILS` flag to `true`,
|
||||
/// and restores the previous value when dropped.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SkipSeriesDetailsGuard {
|
||||
prev: bool,
|
||||
}
|
||||
|
||||
impl SkipSeriesDetailsGuard {
|
||||
#[must_use]
|
||||
pub(crate) fn enter() -> Self {
|
||||
let prev = SKIP_SERIES_DETAILS.with(|flag| flag.replace(true));
|
||||
Self { prev }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_enabled() -> bool { SKIP_SERIES_DETAILS.with(Cell::get) }
|
||||
}
|
||||
|
||||
impl Drop for SkipSeriesDetailsGuard {
|
||||
fn drop(&mut self) { SKIP_SERIES_DETAILS.with(|flag| flag.set(self.prev)); }
|
||||
}
|
||||
|
||||
fn deserialize_series_details<'de, D>(deserializer: D) -> Result<Option<SeriesStreamDetailProperties>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
if SkipSeriesDetailsGuard::is_enabled() {
|
||||
serde::de::IgnoredAny::deserialize(deserializer)?;
|
||||
Ok(None)
|
||||
} else {
|
||||
Option::<SeriesStreamDetailProperties>::deserialize(deserializer)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_episode_code(season: u32, episode: u32) -> Option<String> {
|
||||
match (season, episode) {
|
||||
@@ -182,7 +220,7 @@ pub struct SeriesStreamProperties {
|
||||
pub youtube_trailer: Arc<str>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub tmdb: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "deserialize_series_details")]
|
||||
pub details: Option<SeriesStreamDetailProperties>,
|
||||
}
|
||||
|
||||
@@ -474,3 +512,34 @@ pub(super) fn non_empty_arc(s: &Arc<str>) -> Option<Arc<str>> {
|
||||
Some(Arc::clone(s))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skip_series_details_guard_restores_previous_flag() {
|
||||
assert!(!SkipSeriesDetailsGuard::is_enabled());
|
||||
{
|
||||
let _g1 = SkipSeriesDetailsGuard::enter();
|
||||
assert!(SkipSeriesDetailsGuard::is_enabled());
|
||||
{
|
||||
let _g2 = SkipSeriesDetailsGuard::enter();
|
||||
assert!(SkipSeriesDetailsGuard::is_enabled());
|
||||
}
|
||||
assert!(SkipSeriesDetailsGuard::is_enabled(), "outer guard still active");
|
||||
}
|
||||
assert!(!SkipSeriesDetailsGuard::is_enabled(), "restored to false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_series_details_guard_unwinds_safely() {
|
||||
assert!(!SkipSeriesDetailsGuard::is_enabled());
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _guard = SkipSeriesDetailsGuard::enter();
|
||||
assert!(SkipSeriesDetailsGuard::is_enabled());
|
||||
panic!("test unwind");
|
||||
});
|
||||
assert!(!SkipSeriesDetailsGuard::is_enabled(), "restored to false after unwind");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user