From 65a50e142221815505ffaeba9abf2ff4be22ae6a Mon Sep 17 00:00:00 2001 From: honzup <5564623+honzup@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:00:18 +0200 Subject: [PATCH 1/3] Fix iOS stream restart loop by forwarding Range to ServeFile stream_episode served local files via tower_http ServeFile but built the inner request with no headers, so the client Range header was dropped and every response was 200 + full body. iOS AVPlayer re-fetched from byte 0 on each buffer refill, making YouTube playback restart every ~60s. Forward Range/If-Range/If-Modified-Since/If-None-Match/If-Unmodified-Since into the ServeFile request so it can answer 206 Partial Content. --- rust-api/src/handlers/podcasts.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/rust-api/src/handlers/podcasts.rs b/rust-api/src/handlers/podcasts.rs index a7b867d2a..e6ac0cbf9 100644 --- a/rust-api/src/handlers/podcasts.rs +++ b/rust-api/src/handlers/podcasts.rs @@ -3265,6 +3265,7 @@ pub async fn stream_episode( State(state): State, Path(episode_id): Path, Query(query): Query, + headers: HeaderMap, ) -> Result { let api_key = &query.api_key; info!("Stream request for episode {} with api_key {} and user_id {}", episode_id, api_key, query.user_id); @@ -3355,9 +3356,23 @@ pub async fn stream_episode( use tower::ServiceExt; let service = ServeFile::new(&path); - let request = axum::http::Request::builder() - .method("GET") - .uri("/") + // Forward the range/conditional headers ServeFile needs so it can answer with + // 206 Partial Content. Without them it always returns the full file with 200, and + // iOS AVPlayer re-fetches from byte 0 on every buffer refill (the "stream keeps + // restarting" loop). Only these headers are copied — auth stays in the query string. + let mut builder = axum::http::Request::builder().method("GET").uri("/"); + for name in [ + axum::http::header::RANGE, + axum::http::header::IF_RANGE, + axum::http::header::IF_MODIFIED_SINCE, + axum::http::header::IF_NONE_MATCH, + axum::http::header::IF_UNMODIFIED_SINCE, + ] { + if let Some(value) = headers.get(&name) { + builder = builder.header(name, value); + } + } + let request = builder .body(axum::body::Body::empty()) .map_err(|e| AppError::external_error(&format!("Failed to build request: {}", e)))?; From 61c556d7e67a8f0598a92c7f3a02a0ad466ec387 Mon Sep 17 00:00:00 2001 From: Ashton Kinslow Date: Sat, 17 Oct 2026 20:29:18 -0500 Subject: [PATCH 2/3] fix(streaming): honor If-Range validators and HEAD requests --- .github/workflows/media-stream-tests.yml | 16 ++ rust-api/src/handlers/media_stream.rs | 178 +++++++++++++++++++++++ rust-api/src/handlers/mod.rs | 1 + rust-api/src/handlers/podcasts.rs | 36 +---- 4 files changed, 197 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/media-stream-tests.yml create mode 100644 rust-api/src/handlers/media_stream.rs diff --git a/.github/workflows/media-stream-tests.yml b/.github/workflows/media-stream-tests.yml new file mode 100644 index 000000000..4cda041ab --- /dev/null +++ b/.github/workflows/media-stream-tests.yml @@ -0,0 +1,16 @@ +name: Media Stream Tests +permissions: + contents: read +on: + pull_request: + paths: ["rust-api/**", ".github/workflows/media-stream-tests.yml"] + workflow_dispatch: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: hecrj/setup-rust-action@v2 + with: + rust-version: "1.99.0" + - run: cargo test --locked --manifest-path rust-api/Cargo.toml handlers::media_stream diff --git a/rust-api/src/handlers/media_stream.rs b/rust-api/src/handlers/media_stream.rs new file mode 100644 index 000000000..55d47f27a --- /dev/null +++ b/rust-api/src/handlers/media_stream.rs @@ -0,0 +1,178 @@ +use axum::{ + body::Body, + http::{header, HeaderMap, Method, Request, StatusCode}, + response::Response, +}; +use tower::ServiceExt; +use tower_http::services::ServeFile; + +use crate::error::AppError; + +// The caller must authorize access to this path before serving it. +pub(super) async fn serve_episode_file( + path: &str, + method: Method, + headers: &HeaderMap, +) -> Result { + let mut request = Request::builder().method(method.clone()).uri("/"); + for name in [ + header::RANGE, + header::IF_RANGE, + header::IF_MODIFIED_SINCE, + header::IF_NONE_MATCH, + header::IF_UNMODIFIED_SINCE, + header::IF_MATCH, + ] { + for value in headers.get_all(&name) { + request = request.header(name.clone(), value); + } + } + let request = request + .body(Body::empty()) + .map_err(|e| AppError::external_error(format!("Failed to build request: {e}")))?; + let retry_headers = request.headers().clone(); + let response = ServeFile::new(path) + .oneshot(request) + .await + .map_err(|e| AppError::external_error(format!("Failed to serve file: {e}")))?; + // ServeFile evaluates cache preconditions but does not implement If-Range. + // Only a matching strong validator permits combining partial responses. + if let Some(validator) = headers.get(header::IF_RANGE) { + let matching = !validator.as_bytes().starts_with(b"W/") + && (response.headers().get(header::ETAG) == Some(validator) + || response.headers().get(header::LAST_MODIFIED) == Some(validator)); + if !matching + && matches!( + response.status(), + StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE + ) + { + let mut request = Request::builder() + .method(method) + .uri("/") + .body(Body::empty()) + .map_err(|e| AppError::external_error(format!("Failed to build request: {e}")))?; + *request.headers_mut() = retry_headers; + request.headers_mut().remove(header::RANGE); + request.headers_mut().remove(header::IF_RANGE); + return ServeFile::new(path) + .oneshot(request) + .await + .map(|response| response.map(Body::new)) + .map_err(|e| AppError::external_error(format!("Failed to serve file: {e}"))); + } + } + Ok(response.map(Body::new)) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::to_bytes, http::StatusCode}; + use std::path::PathBuf; + + struct EpisodeFile(PathBuf); + + impl EpisodeFile { + async fn new() -> Self { + let path = std::env::temp_dir().join(format!("pinepods-{}.mp3", uuid::Uuid::new_v4())); + tokio::fs::write(&path, b"0123456789").await.unwrap(); + Self(path) + } + + async fn request(&self, method: Method, pairs: &[(&str, &str)]) -> Response { + let mut headers = HeaderMap::new(); + for &(name, value) in pairs { + headers.append( + name.parse::().unwrap(), + value.parse().unwrap(), + ); + } + serve_episode_file(self.0.to_str().unwrap(), method, &headers) + .await + .unwrap() + } + } + + impl Drop for EpisodeFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + #[tokio::test] + async fn full_file_and_seek_ranges_return_the_requested_bytes() { + let file = EpisodeFile::new().await; + let full = file.request(Method::GET, &[]).await; + assert_eq!(full.status(), StatusCode::OK); + assert_eq!(full.headers()[header::CONTENT_LENGTH], "10"); + assert_eq!(to_bytes(full.into_body(), 10).await.unwrap(), "0123456789"); + + for (range, content_range, expected) in [ + ("bytes=2-5", "bytes 2-5/10", "2345"), + ("bytes=7-", "bytes 7-9/10", "789"), + ("bytes=-2", "bytes 8-9/10", "89"), + ] { + let response = file.request(Method::GET, &[("range", range)]).await; + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[header::CONTENT_RANGE], content_range); + assert_eq!(to_bytes(response.into_body(), 10).await.unwrap(), expected); + } + } + + #[tokio::test] + async fn out_of_bounds_range_is_rejected() { + let file = EpisodeFile::new().await; + let response = file.request(Method::GET, &[("range", "bytes=20-")]).await; + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */10"); + } + + #[tokio::test] + async fn stale_if_range_returns_the_full_resource() { + let file = EpisodeFile::new().await; + let response = file + .request( + Method::GET, + &[ + ("range", "bytes=2-5"), + ("if-range", "Thu, 01 Jan 1970 00:00:00 GMT"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::CONTENT_RANGE)); + assert_eq!( + to_bytes(response.into_body(), 10).await.unwrap(), + "0123456789" + ); + } + + #[tokio::test] + async fn matching_validator_and_head_preserve_file_semantics() { + let file = EpisodeFile::new().await; + let response = file.request(Method::HEAD, &[]).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "10"); + let modified = response.headers()[header::LAST_MODIFIED] + .to_str() + .unwrap() + .to_owned(); + assert!(to_bytes(response.into_body(), 10).await.unwrap().is_empty()); + + let partial = file + .request( + Method::GET, + &[("range", "bytes=2-5"), ("if-range", &modified)], + ) + .await; + assert_eq!(partial.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(to_bytes(partial.into_body(), 10).await.unwrap(), "2345"); + + let cached = file + .request(Method::GET, &[("if-modified-since", &modified)]) + .await; + assert_eq!(cached.status(), StatusCode::NOT_MODIFIED); + assert!(to_bytes(cached.into_body(), 10).await.unwrap().is_empty()); + } +} diff --git a/rust-api/src/handlers/mod.rs b/rust-api/src/handlers/mod.rs index bdba2a9f2..80a66d023 100644 --- a/rust-api/src/handlers/mod.rs +++ b/rust-api/src/handlers/mod.rs @@ -1,3 +1,4 @@ +mod media_stream; pub mod auth; pub mod health; pub mod podcasts; diff --git a/rust-api/src/handlers/podcasts.rs b/rust-api/src/handlers/podcasts.rs index e6ac0cbf9..47201c1e0 100644 --- a/rust-api/src/handlers/podcasts.rs +++ b/rust-api/src/handlers/podcasts.rs @@ -3266,6 +3266,7 @@ pub async fn stream_episode( Path(episode_id): Path, Query(query): Query, headers: HeaderMap, + method: axum::http::Method, ) -> Result { let api_key = &query.api_key; info!("Stream request for episode {} with api_key {} and user_id {}", episode_id, api_key, query.user_id); @@ -3351,40 +3352,7 @@ pub async fn stream_episode( if let Some(path) = file_path { debug!("Found file at: {}", path); - // Use tower_http's ServeFile for proper file serving with range support - use tower_http::services::ServeFile; - use tower::ServiceExt; - - let service = ServeFile::new(&path); - // Forward the range/conditional headers ServeFile needs so it can answer with - // 206 Partial Content. Without them it always returns the full file with 200, and - // iOS AVPlayer re-fetches from byte 0 on every buffer refill (the "stream keeps - // restarting" loop). Only these headers are copied — auth stays in the query string. - let mut builder = axum::http::Request::builder().method("GET").uri("/"); - for name in [ - axum::http::header::RANGE, - axum::http::header::IF_RANGE, - axum::http::header::IF_MODIFIED_SINCE, - axum::http::header::IF_NONE_MATCH, - axum::http::header::IF_UNMODIFIED_SINCE, - ] { - if let Some(value) = headers.get(&name) { - builder = builder.header(name, value); - } - } - let request = builder - .body(axum::body::Body::empty()) - .map_err(|e| AppError::external_error(&format!("Failed to build request: {}", e)))?; - - let response = service.oneshot(request).await - .map_err(|e| AppError::external_error(&format!("Failed to serve file: {}", e)))?; - - // Convert the response body to the expected type - let (parts, body) = response.into_parts(); - let body = axum::body::Body::new(body); - let response = axum::response::Response::from_parts(parts, body); - - Ok(response) + super::media_stream::serve_episode_file(&path, method, &headers).await } else { Err(AppError::not_found("Episode not found or not downloaded")) } From 328cb4e69c07275dc5cc5abf6328f050b9b3a307 Mon Sep 17 00:00:00 2001 From: Ashton Kinslow Date: Sat, 17 Oct 2026 21:06:02 -0500 Subject: [PATCH 3/3] fix(streaming): ignore HEAD ranges and cover validator precedence --- rust-api/src/handlers/media_stream.rs | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/rust-api/src/handlers/media_stream.rs b/rust-api/src/handlers/media_stream.rs index 55d47f27a..14456dd0c 100644 --- a/rust-api/src/handlers/media_stream.rs +++ b/rust-api/src/handlers/media_stream.rs @@ -23,6 +23,10 @@ pub(super) async fn serve_episode_file( header::IF_UNMODIFIED_SINCE, header::IF_MATCH, ] { + // RFC 9110 defines Range only for GET, including when HEAD probes a file. + if method != Method::GET && (name == header::RANGE || name == header::IF_RANGE) { + continue; + } for value in headers.get_all(&name) { request = request.header(name.clone(), value); } @@ -120,6 +124,81 @@ mod tests { } } + #[tokio::test] + async fn head_ignores_bounded_and_unsatisfiable_ranges() { + let file = EpisodeFile::new().await; + for range in ["bytes=2-5", "bytes=20-"] { + let response = file.request(Method::HEAD, &[("range", range)]).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "10"); + assert!(!response.headers().contains_key(header::CONTENT_RANGE)); + assert!(to_bytes(response.into_body(), 10).await.unwrap().is_empty()); + } + } + + #[tokio::test] + async fn if_range_requires_a_matching_strong_etag() { + let file = EpisodeFile::new().await; + let full = file.request(Method::HEAD, &[]).await; + let etag = full.headers()[header::ETAG].to_str().unwrap(); + let weak = format!("W/{etag}"); + for (validator, expected) in [ + (etag, StatusCode::PARTIAL_CONTENT), + (weak.as_str(), StatusCode::OK), + ("\"stale\"", StatusCode::OK), + ] { + let response = file + .request( + Method::GET, + &[("range", "bytes=2-5"), ("if-range", validator)], + ) + .await; + assert_eq!(response.status(), expected); + let expected_body = if expected == StatusCode::PARTIAL_CONTENT { + "2345" + } else { + "0123456789" + }; + assert_eq!( + to_bytes(response.into_body(), 10).await.unwrap(), + expected_body + ); + } + for (validator, expected) in [ + (etag, StatusCode::RANGE_NOT_SATISFIABLE), + ("\"stale\"", StatusCode::OK), + ] { + let response = file + .request( + Method::GET, + &[("range", "bytes=20-"), ("if-range", validator)], + ) + .await; + assert_eq!(response.status(), expected); + } + } + + #[tokio::test] + async fn cache_preconditions_take_precedence_over_ranges() { + let file = EpisodeFile::new().await; + let full = file.request(Method::HEAD, &[]).await; + let etag = full.headers()[header::ETAG].to_str().unwrap(); + let cached = file + .request( + Method::GET, + &[("range", "bytes=2-5"), ("if-none-match", etag)], + ) + .await; + assert_eq!(cached.status(), StatusCode::NOT_MODIFIED); + let failed = file + .request( + Method::GET, + &[("range", "bytes=2-5"), ("if-match", "\"stale\"")], + ) + .await; + assert_eq!(failed.status(), StatusCode::PRECONDITION_FAILED); + } + #[tokio::test] async fn out_of_bounds_range_is_rejected() { let file = EpisodeFile::new().await;