Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/development/stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ be passed instead of a file path:
await stream.stream_file("https://foo.bar/test.mp3")
```

Metadata (title, artist, etc.) is extracted from the first ~32 KB of the HTTP
response before playback begins. Sources smaller than that window return metadata
as usual; larger sources return empty metadata without blocking playback.

#### File Compatibility

It is possible to verify if a file is supported programmatically using
Expand Down
75 changes: 69 additions & 6 deletions pyatv/protocols/raop/audio_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ def __init__(self, reader: io.BufferedIOBase, buffer: SemiSeekableBuffer) -> Non
self.reader: io.BufferedIOBase = reader
self.buffer: SemiSeekableBuffer = buffer
self.name = "stream"
# Tracks the logical position when seek(0, SEEK_END) parks the cursor
# past the last buffer index (which SemiSeekableBuffer cannot represent).
# Cleared by every other seek so that tell() stays consistent.
self._virtual_position: Optional[int] = None

def read(self, size=-1):
"""Read bytes from stream."""
Expand All @@ -147,12 +151,38 @@ def read(self, size=-1):

def seek(self, pos, origin=io.SEEK_SET):
"""Seek to position in stream."""
self._virtual_position = None
if origin == io.SEEK_SET:
self.buffer.seek(pos)
if not self.buffer.seek(pos):
raise OSError(f"cannot seek to absolute position {pos}")
elif origin == io.SEEK_CUR:
absolute = self.buffer.position + pos
if not self.buffer.seek(absolute):
raise OSError(f"cannot seek to position {absolute} (SEEK_CUR)")
elif origin == io.SEEK_END:
# Total bytes currently held in the buffer. For a small stream that
# has been fully downloaded this equals the file size; for a larger
# stream it is however many bytes have been buffered so far.
total = self.buffer.position + self.buffer.size
absolute = total + pos
if absolute > total:
raise OSError(f"cannot seek past end of buffered data (pos={pos})")
if absolute == total:
# End-of-stream is not a valid buffer position (the buffer holds
# indices 0..N-1). Park a virtual position so tell() reflects
# the correct offset; the next seek will clear it.
self._virtual_position = absolute
return absolute
if not self.buffer.seek(absolute):
raise OSError(f"cannot seek to position {absolute} (SEEK_END)")
else:
raise OSError(f"seek whence={origin} is not supported on streaming source")
return self.buffer.position

def tell(self):
"""Return current position in stream."""
if self._virtual_position is not None:
return self._virtual_position
return self.buffer.position

def seekable(self):
Expand Down Expand Up @@ -226,19 +256,49 @@ def __init__(
self.buffer = buffer
# Medafile uses this in error messages, so it helps for debugging
self.name = name
# Tracks the logical position when seek(0, SEEK_END) parks the cursor
# past the last buffer index (which SemiSeekableBuffer cannot represent).
# Cleared by every other seek so that tell() stays consistent.
self._virtual_position: Optional[int] = None

def read(self, size=-1):
"""Read bytes from stream."""
return self.source.read(size)

def seek(self, pos, origin=io.SEEK_SET):
"""Seek to position in stream."""
self._virtual_position = None
if origin == io.SEEK_SET:
self.source.seek(pos, miniaudio.SeekOrigin.START)
if not self.source.seek(pos, miniaudio.SeekOrigin.START):
raise OSError(f"cannot seek to absolute position {pos}")
elif origin == io.SEEK_CUR:
absolute = self.buffer.position + pos
if not self.source.seek(absolute, miniaudio.SeekOrigin.START):
raise OSError(f"cannot seek to position {absolute} (SEEK_CUR)")
elif origin == io.SEEK_END:
# Total bytes currently held in the buffer. For a small stream that
# has been fully downloaded this equals the file size; for a larger
# stream it is however many bytes have been buffered so far.
total = self.buffer.position + self.buffer.size
absolute = total + pos
if absolute > total:
raise OSError(f"cannot seek past end of buffered data (pos={pos})")
if absolute == total:
# End-of-stream is not a valid buffer position (the buffer holds
# indices 0..N-1). Park a virtual position so tell() reflects
# the correct offset; the next seek will clear it.
self._virtual_position = absolute
return absolute
if not self.source.seek(absolute, miniaudio.SeekOrigin.START):
raise OSError(f"cannot seek to position {absolute} (SEEK_END)")
else:
raise OSError(f"seek whence={origin} is not supported on streaming source")
return self.buffer.position

def tell(self):
"""Return current position in stream."""
if self._virtual_position is not None:
return self._virtual_position
return self.buffer.position

def seekable(self):
Expand All @@ -263,11 +323,14 @@ async def get_buffered_io_metadata(buffer: io.BufferedIOBase) -> MediaMetadata:
try:
return await get_metadata(buffer)
except Exception:
logging.exception("Failed to parse metadata")
_LOGGER.exception("Failed to parse metadata")
finally:
buffer.seek(0)
if buffer.seek(before) != before:
logging.warning("Failed to restore position to %d", before)
try:
buffer.seek(0)
if buffer.seek(before) != before:
_LOGGER.warning("Failed to restore position to %d", before)
except OSError:
_LOGGER.warning("Failed to restore position to %d", before)

return EMPTY_METADATA

Expand Down
Loading