fix(proxy): preserve seek range requests after inconclusive HEAD (#437)

## Summary

- keep non-zero Range requests when a HEAD response does not advertise
Accept-Ranges
- use slice caching when the upstream answers 206 and preserve
full-response fallback when it answers 200
- add regression coverage for inconclusive HEAD probes and range
fallback behavior

## Validation

- cargo test -p synctv-proxy --test slice_cache_tests (94 passed)
- cargo check --workspace --all-targets
- cargo fmt --check
- verified Alist, Emby, and Bilibili proxy seeks return correct 206
responses and byte ranges
pull/438/head
zijiren 1 month ago committed by GitHub
parent 882cbe09bb
commit b946d397dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -24,6 +24,7 @@ pub struct CachedResourceMeta {
/// Total size of the resource as reported by upstream. /// Total size of the resource as reported by upstream.
pub total_size: Option<u64>, pub total_size: Option<u64>,
/// Whether upstream has proven byte-range support for this resource. /// Whether upstream has proven byte-range support for this resource.
/// `false` means unknown and must not suppress a later range probe.
pub supports_ranges: bool, pub supports_ranges: bool,
/// Content-Type of the resource. /// Content-Type of the resource.
pub content_type: Option<String>, pub content_type: Option<String>,

@ -425,22 +425,6 @@ async fn proxy_slice_cache(
} }
let cached_meta = cache.get_resource_meta(url, provider_headers); let cached_meta = cache.get_resource_meta(url, provider_headers);
if cached_meta
.as_ref()
.is_some_and(|meta| !meta.supports_ranges)
{
return stream_through_with_status(StreamThroughRequest {
client: cache.client(),
ssrf_guard: cache.ssrf_guard(),
url,
provider_headers,
range_header: None,
cache_status: CacheStatus::Bypass,
request_control,
upstream_header_timeout,
})
.await;
}
let known_total_size = cached_meta.and_then(|meta| meta.total_size); let known_total_size = cached_meta.and_then(|meta| meta.total_size);
if let Some(total_size) = known_total_size { if let Some(total_size) = known_total_size {
range_bounds_for_total(plan, total_size).map_err(proxy_error_from_client_range_error)?; range_bounds_for_total(plan, total_size).map_err(proxy_error_from_client_range_error)?;

@ -1094,10 +1094,10 @@ async fn test_proxy_head_with_cache_uses_head_and_reuses_cached_metadata() {
} }
#[tokio::test] #[tokio::test]
async fn test_head_without_accept_ranges_stores_length_without_range_support() { async fn test_head_without_accept_ranges_does_not_block_later_range_probe() {
let mock_server = MockServer::start().await; let mock_server = MockServer::start().await;
let total_size: u64 = 4096; let total_size: u64 = 4096;
let full_body = Bytes::from(vec![0xE1; 4096]); let slice_body = Bytes::from((0_u8..=u8::MAX).cycle().take(1024).collect::<Vec<_>>());
Mock::given(method("HEAD")) Mock::given(method("HEAD"))
.and(path("/head-no-range.bin")) .and(path("/head-no-range.bin"))
@ -1112,19 +1112,12 @@ async fn test_head_without_accept_ranges_stores_length_without_range_support() {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/head-no-range.bin")) .and(path("/head-no-range.bin"))
.and(header("Range", "bytes=0-1023")) .and(header("Range", "bytes=2048-3071"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/head-no-range.bin"))
.and(HeaderAbsent("Range"))
.respond_with( .respond_with(
ResponseTemplate::new(200) ResponseTemplate::new(206)
.set_body_bytes(full_body.clone()) .set_body_bytes(slice_body.clone())
.insert_header("Content-Length", total_size.to_string()), .insert_header("Content-Range", format!("bytes 2048-3071/{total_size}"))
.insert_header("Content-Length", "1024"),
) )
.expect(1) .expect(1)
.mount(&mock_server) .mount(&mock_server)
@ -1155,17 +1148,21 @@ async fn test_head_without_accept_ranges_stores_length_without_range_support() {
"Content-Length alone must not prove range support" "Content-Length alone must not prove range support"
); );
let range = proxy_slice(&cache, Some("bytes=0-511"), &url, &provider_headers) let range = proxy_slice(&cache, Some("bytes=2304-2559"), &url, &provider_headers)
.await .await
.unwrap(); .unwrap();
assert_eq!(range.status(), StatusCode::OK); assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
range.headers().get("Content-Range").unwrap(),
"bytes 2304-2559/4096"
);
assert_eq!( assert_eq!(
range.headers().get("X-Cache-Status").unwrap(), range.headers().get("X-Cache-Status").unwrap(),
CacheStatus::Bypass.as_str() CacheStatus::Miss.as_str()
); );
assert_eq!( assert_eq!(
range.into_body().collect().await.unwrap().to_bytes(), range.into_body().collect().await.unwrap().to_bytes(),
full_body slice_body.slice(256..512)
); );
} }
@ -2369,8 +2366,13 @@ async fn test_suffix_range_with_head_length_bypasses_when_origin_ignores_aligned
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/suffix-no-ranges.bin")) .and(path("/suffix-no-ranges.bin"))
.and(header("Range", "bytes=3072-4095")) .and(header("Range", "bytes=3072-4095"))
.respond_with(ResponseTemplate::new(500)) .respond_with(
.expect(0) ResponseTemplate::new(200)
.set_body_bytes(full_body.clone())
.insert_header("Content-Length", total_size.to_string())
.insert_header("Content-Type", "application/octet-stream"),
)
.expect(1)
.mount(&mock_server) .mount(&mock_server)
.await; .await;
@ -2383,7 +2385,7 @@ async fn test_suffix_range_with_head_length_bypasses_when_origin_ignores_aligned
.insert_header("Content-Length", total_size.to_string()) .insert_header("Content-Length", total_size.to_string())
.insert_header("Content-Type", "application/octet-stream"), .insert_header("Content-Type", "application/octet-stream"),
) )
.expect(1) .expect(0)
.mount(&mock_server) .mount(&mock_server)
.await; .await;

Loading…
Cancel
Save