Skip to content

raop: honor relative seeks in streaming audio sources - #2921

Closed
hippi345 wants to merge 3 commits into
postlund:masterfrom
hippi345:fix/raop-relative-seek
Closed

hippi345 wants to merge 3 commits into
postlund:masterfrom
hippi345:fix/raop-relative-seek

Conversation

@hippi345

@hippi345 hippi345 commented Oct 9, 2026

Copy link
Copy Markdown

Fixes #2910

Streaming an HTTP source stalled for about 10 s before the first sample, as diagnosed in the issue. StreamableSourceWrapper.seek and BufferedIOBaseWrapper.seek only handled SEEK_SET. For SEEK_CUR and SEEK_END they did nothing but still returned a position that looked like success, so TinyTag fell back to reading the stream linearly, ran past the parked 64 KiB buffer, and PatchedIceCastClient.read waited out DEFAULT_TIMEOUT.

Changes

pyatv/protocols/raop/audio_source.py:

  • seek on both wrappers:
    • SEEK_CUR is converted to an absolute position.
    • SEEK_END resolves against the bytes buffered so far (position + size). seek(0, SEEK_END) parks a virtual position so tell() reports the size, which keeps metadata working for small, fully buffered streams.
    • A failed seek raises OSError instead of returning a stale position, so TinyTag gives up right away rather than walking the stream.
  • get_buffered_io_metadata:
    • Restoring the stream position after metadata parsing can no longer raise; a failure logs the existing warning.
    • Its log calls now go through the module logger instead of the root logger (suggestion 3 in the issue).

docs/development/stream.md: a short note on the HTTP metadata window and what happens with larger sources.

This doesn't touch PatchedIceCastClient.read or tests/protocols/raop/test_audio_source.py, so it shouldn't conflict with #2850.

Testing

  • New tests/protocols/raop/test_audio_source_seek.py:
    • SEEK_SET, SEEK_CUR and SEEK_END behavior and error paths for both wrappers, including seek(0, SEEK_END) after a partial read.
    • A small tagged WAV still returns its title and artist through get_buffered_io_metadata; this passes on master and with the fix.
    • A WAV larger than BUFFER_SIZE now returns in well under 2 s; on master it takes about 10 s and fails.
    • A failing position restore doesn't raise and logs the warning.
  • On master, the relative-seek and timing tests fail.
  • Full suite: 1265 passed, 5 skipped.
  • black, isort, flake8, pylint and pydocstyle are clean. The chickn protobuf step couldn't run without network access.

StreamableSourceWrapper.seek and BufferedIOBaseWrapper.seek only handled
io.SEEK_SET; for io.SEEK_CUR and io.SEEK_END they silently returned the
current position, making callers believe the seek succeeded.

During metadata parsing (get_buffered_io_metadata) TinyTag issues
seek(0, SEEK_END) to probe file size.  With the broken implementation that
call returned the current position (0) without moving; TinyTag inferred
the file had zero length and fell back to a linear read.  For an HTTP
source backed by PatchedIceCastClient the linear read quickly exhausted
the 64 KiB pre-buffer and entered the polling loop, always hitting the
full DEFAULT_TIMEOUT (10 s) before the read was satisfied.

Fix:
* io.SEEK_CUR: convert to an absolute offset and delegate to the existing
  SEEK_SET path on the underlying source/buffer.  Raise OSError if the
  source rejects the resulting absolute position (e.g. headroom exhausted).
* io.SEEK_SET: propagate a False return from the underlying source as
  OSError instead of silently returning the stale position.
* Any other whence (io.SEEK_END): raise OSError immediately, since
  streaming sources do not know their total size.

Raising OSError causes TinyTag to abort metadata parsing at the first
unsupported seek rather than walking the entire stream; the exception is
caught by the existing except-block in get_buffered_io_metadata, which
returns EMPTY_METADATA.  The 10 s stall is eliminated.

Also route the 'Failed to parse metadata' log message through the module
logger (_LOGGER) rather than the root logger so the failure is
attributable to pyatv.protocols.raop.audio_source.
SEEK_END now returns the total buffered size for pos=0 and raises
OSError for pos>0 (seeking past end).  When seek(0, SEEK_END) parks
the cursor at a position that SemiSeekableBuffer cannot represent
(total = position + size, which is one past the last valid index),
a _virtual_position is stored and returned by tell() so that callers
such as TinyTag can read the file size without a subsequent tell()
returning the stale pre-seek position (0).  The virtual position is
cleared by the next seek() call.

Without this, TinyTag 2.x called seek(0, SEEK_END) then tell() to
obtain the filesize; tell() returned 0, so TinyTag skipped _load()
entirely and returned empty metadata for every stream, including
small ones that were fully buffered.

Tests added:
- seek(0, SEEK_END) followed by tell() asserts the correct size for
  both StreamableSourceWrapper and BufferedIOBaseWrapper
- test_metadata_small_buffered_stream_preserved: a 2548-byte tagged
  WAV fully within the seekable headroom yields title/artist via
  get_buffered_io_metadata (guards against the always-raise regression)
- test_metadata_large_stream_returns_fast: a source larger than
  BUFFER_SIZE returns in < 2 s instead of stalling ~10 s (DEFAULT_TIMEOUT)

docs/development/stream.md: note on HTTP metadata extraction window
seek() now raises OSError on failure instead of silently returning the
wrong position.  The finally-block in get_buffered_io_metadata called
buffer.seek(0) and then buffer.seek(before) to restore the stream
cursor after metadata parsing; either call could now propagate an
OSError out of the function, breaking playback.

Wrap the restore in try/except OSError and log the existing warning
('Failed to restore position to %d') on any failure so the caller
always receives a MediaMetadata (or EMPTY_METADATA) without an
exception.

Also verified: total = buffer.position + buffer.size is correct for all
SemiSeekableBuffer states.  size returns bytes remaining from position,
so position + size = total bytes buffered, which is invariant across
seek and read operations.  Tests confirm this at a non-zero cursor.

Tests added:
- test_seek_end_returns_buffered_size (both classes): now reads 50 bytes
  first to confirm the total invariant holds when position != 0
- test_metadata_restore_failure_does_not_raise: patches seek to raise
  OSError during the restore phase; asserts no exception propagates and
  that the warning is emitted
@hippi345 hippi345 closed this Oct 9, 2026
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.

RAOP: an HTTP source costs a fixed ~10 s before first audio once it exceeds the buffer (StreamableSourceWrapper.seek ignores relative seeks)

1 participant