Skip to content

Forward Range header to ServeFile when streaming episodes - #986

Open
honzup wants to merge 1 commit into
madeofpendletonwool:mainfrom
honzup:fix/stream-range-requests
Open

honzup wants to merge 1 commit into
madeofpendletonwool:mainfrom
honzup:fix/stream-range-requests

Conversation

@honzup

@honzup honzup commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #980.

Independent implementation of the fix @davegallant diagnosed in #980. Their fork commit forwards Range; this also forwards If-Range and the other conditional headers (see Change).

What

stream_episode serves downloaded and local episode files through tower_http's ServeFile, which handles HTTP range requests. But it builds the inner request with an empty body and no headers, so the client's Range header is discarded before ServeFile ever sees it. Every response is a 200 OK with the full file and no 206 Partial Content is produced - even though the response still advertises Accept-Ranges: bytes.

This forwards the range and conditional headers (Range, If-Range, If-Modified-Since, If-None-Match, If-Unmodified-Since) from the incoming request onto the request handed to ServeFile, so it can answer with 206 when the client asks for a byte range.

Why it matters

Clients that depend on range requests to seek and to buffer ahead - the iOS native player (AVPlayer) most notably - treat a full-file 200 where they expected a 206 as a brand-new resource load. Playback then jumps back to the start on roughly every buffer refill, which makes downloaded and YouTube episodes unusable on iOS. RSS episodes streamed straight from the publisher are unaffected, since those requests never reach this endpoint. Seeking also could not work on any client, because the server always returned the whole file from byte 0.

Change

Add a HeaderMap extractor to stream_episode and copy the range and conditional headers onto the ServeFile request. Only those headers are forwarded; the API key stays in the query string as before.

Forwarding If-Range as well as Range means that if the file on disk changes between requests - for example a server-side re-download replacing the mp3 - ServeFile correctly falls back to a full 200 rather than splicing new bytes onto the client's stale partial.

Testing

Built the rust-api crate against this branch, then checked a running instance:

  • Range: bytes=0-1023 now returns HTTP/1.1 206 Partial Content with Content-Range: bytes 0-1023/<total> and Content-Length: 1024.
  • A request with no Range header still returns HTTP/1.1 200 OK with the full file.
  • End to end, a downloaded episode that previously restarted every minute on iOS now plays straight through.

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.
@honzup

honzup commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

@madeofpendletonwool for when you get to this one: @ashtonian has opened #1012, which keeps this commit as-is and builds on it. It tightens the If-Range handling, returns the right response for HEAD requests, and adds regression tests for the streaming endpoint.

I'm happy either way. If you'd rather take the fuller version, merge #1012 and I'll close this. If you'd prefer to land the small fix first, this one is ready and #1012 can sync on top of it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS cannot resume or seek episodes downloaded on the PinePods server

1 participant