From 0c30cdfcc6d9f21303d7361b8045969466480026 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 14 Aug 2026 11:42:54 +0100 Subject: [PATCH] Added ogg opus support --- Cargo.lock | 11 +- Cargo.toml | 7 +- src/player/mod.rs | 67 ++++--- src/player/opus.rs | 433 +++++++++++++++++++++++++++++++++++++++++++++ src/similarity.rs | 13 +- src/streaming.rs | 35 +++- 6 files changed, 532 insertions(+), 34 deletions(-) create mode 100644 src/player/opus.rs diff --git a/Cargo.lock b/Cargo.lock index 7298fce..e66645a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1666,9 +1666,10 @@ dependencies = [ [[package]] name = "furumi_tui" -version = "0.2.6" +version = "0.2.7" dependencies = [ "anyhow", + "base64", "blake3", "core-foundation 0.10.1", "crokey", @@ -1686,10 +1687,12 @@ dependencies = [ "rodio", "rusqlite", "rustfft", + "rusty-opus", "serde", "serde_json", "sha2 0.10.9", "souvlaki", + "symphonia", "thiserror 2.0.20", "tokio", "toml", @@ -4991,6 +4994,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-opus" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2cbb720cf9bed36712efbae9ffc89d8977bf51b05687b792c1f98a33add8e2" + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 510eaf8..7de9c0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi_tui" -version = "0.2.6" +version = "0.2.7" edition = "2024" rust-version = "1.97" description = "A federated P2P player for personal music libraries" @@ -27,12 +27,14 @@ ratatui = "0.30.1" reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] } rhai = { version = "1", features = ["sync"] } rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } +rusty-opus = "0.9.1" rustfft = "6.4.1" rusqlite = { version = "0.32", features = ["bundled", "functions"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.10.9" souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } +symphonia = { version = "0.5.5", default-features = false, features = ["ogg"] } thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] } toml = "1.1.2" @@ -54,3 +56,6 @@ windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_Wi [target."cfg(unix)".dependencies] libc = "0.2.186" + +[dev-dependencies] +base64 = "0.22.1" diff --git a/src/player/mod.rs b/src/player/mod.rs index 399e203..c707551 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -5,13 +5,14 @@ //! the UI or app state. mod analyzer; +mod opus; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; use std::time::Duration; -use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink}; +use rodio::{Decoder, DeviceSinkBuilder, Player, Source, stream::MixerDeviceSink}; pub use analyzer::AudioAnalysisSnapshot; @@ -226,23 +227,13 @@ fn handle( } let out = output.as_ref().expect("output opened above"); - let mut builder = Decoder::builder() - .with_data(reader) - .with_seekable(seekable) - .with_gapless(true); - if let Some(len) = byte_len { - builder = builder.with_byte_len(len); - } - if let Some(mime_type) = mime_type.as_deref() { - builder = builder.with_mime_type(mime_type); - } - match builder.build() { - Ok(decoder) => { + match decode_source(reader, byte_len, mime_type.as_deref(), seekable) { + Ok(source) => { shared.analysis.clear(); out.player.stop(); out.player.set_volume(volume); out.player.append(analyzer::AnalyzedSource::new( - decoder, + source, Arc::clone(&shared.analysis), )); out.player.play(); @@ -258,16 +249,9 @@ fn handle( let Some(out) = output.as_ref() else { return; }; - let mut builder = Decoder::builder() - .with_data(reader) - .with_seekable(true) - .with_gapless(true); - if let Some(len) = byte_len { - builder = builder.with_byte_len(len); - } - match builder.build() { - Ok(decoder) => out.player.append(analyzer::AnalyzedSource::new( - decoder, + match decode_source(reader, byte_len, None, true) { + Ok(source) => out.player.append(analyzer::AnalyzedSource::new( + source, Arc::clone(&shared.analysis), )), Err(err) => { @@ -309,3 +293,38 @@ fn handle( } } } + +pub(crate) type DecodedSource = Box; + +pub(crate) fn decode_source( + mut reader: TrackReader, + byte_len: Option, + mime_type: Option<&str>, + seekable: bool, +) -> Result { + let mime_is_opus = mime_type.is_some_and(|mime| { + let mime = mime.to_ascii_lowercase(); + mime == "audio/opus" || mime.contains("codecs=opus") || mime.contains("codecs=\"opus\"") + }); + let ogg_is_opus = opus::is_ogg_opus(&mut reader) + .map_err(|error| format!("cannot inspect audio stream: {error}"))?; + if mime_is_opus || ogg_is_opus { + return opus::OggOpusSource::new(reader, byte_len, seekable) + .map(|source| Box::new(source) as DecodedSource); + } + + let mut builder = Decoder::builder() + .with_data(reader) + .with_seekable(seekable) + .with_gapless(true); + if let Some(len) = byte_len { + builder = builder.with_byte_len(len); + } + if let Some(mime_type) = mime_type { + builder = builder.with_mime_type(mime_type); + } + builder + .build() + .map(|decoder| Box::new(decoder) as DecodedSource) + .map_err(|error| error.to_string()) +} diff --git a/src/player/opus.rs b/src/player/opus.rs new file mode 100644 index 0000000..5620c37 --- /dev/null +++ b/src/player/opus.rs @@ -0,0 +1,433 @@ +//! Ogg/Opus decoding for rodio. +//! +//! Rodio 0.22 can demux Ogg and decode Vorbis, but its Symphonia version does +//! not include an Opus decoder. This source keeps Symphonia's mature Ogg +//! demuxing and feeds the packets into the pure-Rust `rusty-opus` decoder. + +use std::io::{self, Read, Seek, SeekFrom}; +use std::sync::Arc; +use std::time::Duration; + +use rodio::source::SeekError; +use rodio::{ChannelCount, SampleRate, Source}; +use rusty_opus::OpusDecoder; +use symphonia::core::codecs::{CODEC_TYPE_OPUS, CodecParameters}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; +use symphonia::core::io::{MediaSource, MediaSourceStream}; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +use super::TrackReader; + +const OPUS_SAMPLE_RATE: u32 = 48_000; +const OPUS_SEEK_PREROLL_FRAMES: u64 = OPUS_SAMPLE_RATE as u64 * 80 / 1_000; +const SNIFF_BYTES: usize = 512; + +/// Inspect the Ogg identification page without changing the reader position. +pub(super) fn is_ogg_opus(reader: &mut TrackReader) -> io::Result { + let position = reader.stream_position()?; + let mut header = [0; SNIFF_BYTES]; + let read_result = reader.read(&mut header); + let rewind_result = reader.seek(SeekFrom::Start(position)); + + let read = read_result?; + rewind_result?; + Ok(header[..read].starts_with(b"OggS") + && header[..read] + .windows(b"OpusHead".len()) + .any(|window| window == b"OpusHead")) +} + +struct ReaderMediaSource { + reader: TrackReader, + byte_len: Option, + seekable: bool, +} + +impl Read for ReaderMediaSource { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.reader.read(buffer) + } +} + +impl Seek for ReaderMediaSource { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.reader.seek(position) + } +} + +impl MediaSource for ReaderMediaSource { + fn is_seekable(&self) -> bool { + self.seekable + } + + fn byte_len(&self) -> Option { + self.byte_len + } +} + +pub(super) struct OggOpusSource { + format: Box, + decoder: OpusDecoder, + track_id: u32, + channels: u16, + pre_skip: u64, + playable_frames: Option, + output_position_frames: u64, + discard_frames: u64, + output_gain: f32, + buffer: Vec, + scratch: Vec, + buffer_position: usize, + seekable: bool, + done: bool, +} + +impl OggOpusSource { + pub(super) fn new( + reader: TrackReader, + byte_len: Option, + seekable: bool, + ) -> Result { + let source = ReaderMediaSource { + reader, + byte_len, + seekable, + }; + let stream = MediaSourceStream::new(Box::new(source), Default::default()); + let mut hint = Hint::new(); + hint.with_extension("ogg"); + hint.mime_type("audio/ogg"); + let format_options = FormatOptions { + enable_gapless: false, + ..Default::default() + }; + let probed = symphonia::default::get_probe() + .format(&hint, stream, &format_options, &MetadataOptions::default()) + .map_err(|error| format!("cannot read Ogg container: {error}"))?; + let format = probed.format; + + let (track_id, channels, pre_skip, playable_frames, output_gain) = { + let track = format + .default_track() + .ok_or_else(|| "Ogg container has no audio track".to_string())?; + if track.codec_params.codec != CODEC_TYPE_OPUS { + return Err("Ogg track is not Opus".to_string()); + } + let channels = track + .codec_params + .channels + .map(|channels| channels.count() as u16) + .ok_or_else(|| "Opus track has no channel layout".to_string())?; + if !(1..=2).contains(&channels) { + return Err(format!( + "Opus track has {channels} channels; only mono and stereo are supported" + )); + } + let extra_data = track.codec_params.extra_data.as_deref(); + let pre_skip = opus_pre_skip(extra_data); + let playable_frames = opus_playable_frames(&track.codec_params, pre_skip); + let output_gain = opus_output_gain(extra_data); + (track.id, channels, pre_skip, playable_frames, output_gain) + }; + let decoder = OpusDecoder::new(OPUS_SAMPLE_RATE as i32, usize::from(channels)) + .map_err(|error| format!("cannot initialize Opus decoder: {error}"))?; + + let mut source = Self { + format, + decoder, + track_id, + channels, + pre_skip, + playable_frames, + output_position_frames: 0, + discard_frames: pre_skip, + output_gain, + buffer: Vec::new(), + scratch: Vec::new(), + buffer_position: 0, + seekable, + done: false, + }; + source.fill_buffer(); + if source.buffer.is_empty() { + return Err("Opus track contains no decodable audio".to_string()); + } + Ok(source) + } + + fn fill_buffer(&mut self) { + self.buffer.clear(); + self.buffer_position = 0; + + while self.buffer.is_empty() && !self.done { + let packet = match self.format.next_packet() { + Ok(packet) => packet, + Err(SymphoniaError::IoError(error)) + if error.kind() == io::ErrorKind::UnexpectedEof => + { + self.done = true; + break; + } + Err(error) => { + tracing::warn!(%error, "Ogg/Opus demux failed"); + self.done = true; + break; + } + }; + if packet.track_id() != self.track_id { + continue; + } + + let Ok(decoded_frames) = usize::try_from(packet.dur) else { + tracing::warn!("Ogg/Opus packet duration is too large"); + self.done = true; + break; + }; + if decoded_frames == 0 { + continue; + } + + let sample_count = match decoded_frames.checked_mul(usize::from(self.channels)) { + Some(sample_count) => sample_count, + None => { + tracing::warn!("Ogg/Opus packet sample count overflow"); + self.done = true; + break; + } + }; + self.scratch.resize(sample_count, 0.0); + let frames = match self + .decoder + .decode(&packet.data, decoded_frames, &mut self.scratch) + { + Ok(frames) => frames, + Err(error) => { + tracing::warn!(%error, "Opus packet decode failed"); + continue; + } + }; + + let discarded = self.discard_frames.min(frames as u64) as usize; + self.discard_frames -= discarded as u64; + let start_frame = discarded; + let remaining = self + .playable_frames + .map(|total| total.saturating_sub(self.output_position_frames)) + .unwrap_or(u64::MAX); + let end_frame = frames + .min(start_frame.saturating_add(usize::try_from(remaining).unwrap_or(usize::MAX))); + if start_frame >= end_frame { + if self + .playable_frames + .is_some_and(|total| self.output_position_frames >= total) + { + self.done = true; + } + continue; + } + + let channels = usize::from(self.channels); + let start = start_frame * channels; + let end = end_frame.saturating_mul(channels).min(self.scratch.len()); + self.output_position_frames = self + .output_position_frames + .saturating_add((end_frame - start_frame) as u64); + self.buffer.extend( + self.scratch[start..end] + .iter() + .map(|sample| (sample * self.output_gain).clamp(-1.0, 1.0)), + ); + if self + .playable_frames + .is_some_and(|total| self.output_position_frames >= total) + { + self.done = true; + } + } + } + + fn reset_decoder(&mut self) -> Result<(), SeekError> { + self.decoder = OpusDecoder::new(OPUS_SAMPLE_RATE as i32, usize::from(self.channels)) + .map_err(|error| seek_error(format!("cannot reset Opus decoder: {error}")))?; + Ok(()) + } +} + +impl Iterator for OggOpusSource { + type Item = f32; + + fn next(&mut self) -> Option { + loop { + if self.buffer_position < self.buffer.len() { + let sample = self.buffer[self.buffer_position]; + self.buffer_position += 1; + return Some(sample); + } + if self.done { + return None; + } + self.fill_buffer(); + } + } + + fn size_hint(&self) -> (usize, Option) { + (self.buffer.len().saturating_sub(self.buffer_position), None) + } +} + +impl Source for OggOpusSource { + fn current_span_len(&self) -> Option { + Some(self.buffer.len()) + } + + fn channels(&self) -> ChannelCount { + ChannelCount::new(self.channels).expect("Opus channel count was validated") + } + + fn sample_rate(&self) -> SampleRate { + SampleRate::new(OPUS_SAMPLE_RATE).expect("Opus sample rate is non-zero") + } + + fn total_duration(&self) -> Option { + self.playable_frames + .map(|frames| Duration::from_secs_f64(frames as f64 / f64::from(OPUS_SAMPLE_RATE))) + } + + fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> { + if !self.seekable { + return Err(SeekError::NotSupported { + underlying_source: std::any::type_name::(), + }); + } + + let requested = duration_frames(position); + let target = self + .playable_frames + .map_or(requested, |total| requested.min(total)); + let raw_target = target.saturating_add(self.pre_skip); + let preroll = raw_target.saturating_sub(OPUS_SEEK_PREROLL_FRAMES); + let seeked = self + .format + .seek( + SeekMode::Accurate, + SeekTo::TimeStamp { + ts: preroll, + track_id: self.track_id, + }, + ) + .map_err(|error| seek_error(format!("Ogg seek failed: {error}")))?; + self.reset_decoder()?; + self.buffer.clear(); + self.buffer_position = 0; + self.output_position_frames = target; + self.discard_frames = raw_target.saturating_sub(seeked.actual_ts); + self.done = false; + self.fill_buffer(); + Ok(()) + } +} + +fn duration_frames(duration: Duration) -> u64 { + let frames = duration.as_secs_f64() * f64::from(OPUS_SAMPLE_RATE); + frames.round().clamp(0.0, u64::MAX as f64) as u64 +} + +fn opus_pre_skip(extra_data: Option<&[u8]>) -> u64 { + extra_data + .filter(|header| header.len() >= 12) + .map(|header| u64::from(u16::from_le_bytes([header[10], header[11]]))) + .unwrap_or(0) +} + +fn opus_playable_frames(params: &CodecParameters, pre_skip: u64) -> Option { + let encoded_frames = params.n_frames?; + let padding = u64::from(params.padding.unwrap_or(0)); + + // Symphonia normally leaves the OpusHead pre-skip in `delay`. For a very + // short stream whose first audio page is also its last page, it instead + // reports the page's trailing padding there. Preserve that information so + // both one-page and ordinary Ogg/Opus streams end on the correct sample. + let one_page_padding = params + .delay + .map(u64::from) + .filter(|delay| *delay != pre_skip) + .unwrap_or(0); + + Some( + encoded_frames + .saturating_sub(pre_skip) + .saturating_sub(padding) + .saturating_sub(one_page_padding), + ) +} + +fn opus_output_gain(extra_data: Option<&[u8]>) -> f32 { + let Some(header) = extra_data.filter(|header| header.len() >= 18) else { + return 1.0; + }; + let gain_q8 = i16::from_le_bytes([header[16], header[17]]); + 10.0_f32.powf(f32::from(gain_q8) / (20.0 * 256.0)) +} + +fn seek_error(message: String) -> SeekError { + SeekError::Other(Arc::new(io::Error::other(message))) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use base64::Engine as _; + + use super::*; + + // 30 ms mono Ogg/Opus sine, encoded by FFmpeg/libopus. Keeping a real + // interoperable stream here catches container and codec regressions. + const OGG_OPUS: &str = "T2dnUwACAAAAAAAAAABkiBtVAAAAAFZb1toBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAAAAAAAAAAAGSIG1UBAAAAq+b2jQE+T3B1c1RhZ3MNAAAATGF2ZjYyLjEyLjEwMgEAAAAdAAAAZW5jb2Rlcj1MYXZjNjIuMjguMTAyIGxpYm9wdXNPZ2dTAATYBgAAAAAAAGSIG1UCAAAAEkNnrgT/NP8x+HJJRycQ5MhbeCeLhvfY79vodePsNuYR8hAn2iaItTjKzvL0UULTvai7BFd4FJ2BZdtZQn4K2vCS9rjadnLmp+u4QMCAJiL3MXbVJBVHjtDhATQq5rg5ZlscpBXWk/NqnQE/QaT/nhMi/VqnLKwRCFcpFnH/NqPx7RhjpvzicAu/blC/sRygALOf6blR6HYkOb7BLn/Vn3ijqdTVwCzwtcpWU2hbCXUOWnTEuXUOWaOmQKy8zmqYgzM7aOueo/2siHPpomLeiHPp4NH0cpD63qkSv4/XxX8HWdBtg9IvLpQ42ch0PrqI7BR4ZhvJoDmg45q/177KfOUxlfoG/8GpJWKTCpaN1u64it+1vkpE7sfaLIuSVNbz6BDOIREzM5pPkdIyBzqThkbRkGJr8KDGTyQyJfi0JXhIR5fGM2RptXd5mTRUZHmCz9dCMyKay/nzCir7oSDyEbWNlfaV7qzrxPXTNJRpJ/6GgfI3Ht2Y9jKPV8WkGzGO/Pyz+hznJTgJdGOpxRmzAWXmcwcSdI0TjPVLIs8SIBtipLhr0R+yDe2ar7ZQR3hxQzoIn5ydLO7mk8QZvr+4gAAAAAAAAAAAAAAAAAAAAAAAAE+e11+HmY5uncnz1m153WYm1urhAnMTa3qa3G/MIezSH+urKDn4eZAnEgz8PMnPgAOL22PlLS7647fYrYbJfnfYvLgwLpXSlHeh7VHf+GQd99vAzM/086lHsksCvwXkd12GFX2BTQbtW/3wPMmi4zL6VsKJUH/Q5ZQ00cvMtFDgSw5CLIg8nHONgH2/XVeZC+VwngKBeuzHHK4="; + + fn fixture() -> Vec { + base64::engine::general_purpose::STANDARD + .decode(OGG_OPUS) + .unwrap() + } + + #[test] + fn detects_and_decodes_ogg_opus() { + let bytes = fixture(); + let len = bytes.len() as u64; + let mut reader: TrackReader = Box::new(Cursor::new(bytes)); + assert!(is_ogg_opus(&mut reader).unwrap()); + + let source = OggOpusSource::new(reader, Some(len), true).unwrap(); + assert_eq!(source.channels().get(), 1); + assert_eq!(source.sample_rate().get(), OPUS_SAMPLE_RATE); + let samples: Vec<_> = source.collect(); + assert_eq!(samples.len(), 1_440); + assert!(samples.iter().any(|sample| sample.abs() > 0.001)); + } + + #[test] + fn seeks_within_ogg_opus() { + let bytes = fixture(); + let len = bytes.len() as u64; + let reader: TrackReader = Box::new(Cursor::new(bytes)); + let mut source = OggOpusSource::new(reader, Some(len), true).unwrap(); + + assert_eq!(source.by_ref().count(), 1_440); + source.try_seek(Duration::from_millis(15)).unwrap(); + let samples: Vec<_> = source.collect(); + assert_eq!(samples.len(), 720); + assert!(samples.iter().any(|sample| sample.abs() > 0.001)); + } + + #[test] + fn ogg_vorbis_header_is_not_misclassified_as_opus() { + let mut bytes = + b"OggS\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x01\x1e\x01vorbis".to_vec(); + bytes.resize(SNIFF_BYTES, 0); + let mut reader: TrackReader = Box::new(Cursor::new(bytes)); + assert!(!is_ogg_opus(&mut reader).unwrap()); + } +} diff --git a/src/similarity.rs b/src/similarity.rs index 60f7907..4dfb856 100644 --- a/src/similarity.rs +++ b/src/similarity.rs @@ -13,7 +13,7 @@ use std::time::Instant; use anyhow::{Context as _, Result}; use futures_util::StreamExt as _; -use rodio::{Decoder, Source as _}; +use rodio::Source as _; use rustfft::FftPlanner; use rustfft::num_complex::Complex; use sha2::{Digest as _, Sha256}; @@ -847,8 +847,15 @@ fn decode_mono_window( length_seconds: Option, ) -> Result> { let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; - let mut decoder = - Decoder::try_from(file).with_context(|| format!("decoding {}", path.display()))?; + let byte_len = file.metadata().ok().map(|metadata| metadata.len()); + let mut decoder = crate::player::decode_source( + Box::new(std::io::BufReader::new(file)), + byte_len, + None, + true, + ) + .map_err(anyhow::Error::msg) + .with_context(|| format!("decoding {}", path.display()))?; let channels = decoder.channels().get() as usize; let source_rate = decoder.sample_rate().get() as usize; if start_seconds > 0.0 { diff --git a/src/streaming.rs b/src/streaming.rs index 29c9122..c5eee63 100644 --- a/src/streaming.rs +++ b/src/streaming.rs @@ -101,11 +101,30 @@ impl Read for GrowingFileReader { } impl Seek for GrowingFileReader { - fn seek(&mut self, _: SeekFrom) -> io::Result { - Err(io::Error::new( - io::ErrorKind::Unsupported, - "streaming playback is not seekable yet", - )) + fn seek(&mut self, position: SeekFrom) -> io::Result { + // Random access beyond the downloaded prefix is intentionally not + // exposed, but decoders may inspect and rewind the available header. + let available = self + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .available; + let target = match position { + SeekFrom::Start(position) => i128::from(position), + SeekFrom::Current(offset) => i128::from(self.pos) + i128::from(offset), + SeekFrom::End(offset) => i128::from(available) + i128::from(offset), + }; + if target < 0 || target > i128::from(available) { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "cannot seek outside the downloaded audio prefix", + )); + } + let target = target as u64; + self.file.seek(SeekFrom::Start(target))?; + self.pos = target; + Ok(target) } } @@ -129,6 +148,12 @@ mod tests { reader.read_exact(&mut first).unwrap(); assert_eq!(&first, b"fur"); + reader.seek(SeekFrom::Start(0)).unwrap(); + let mut rewind = [0u8; 3]; + reader.read_exact(&mut rewind).unwrap(); + assert_eq!(&rewind, b"fur"); + assert!(reader.seek(SeekFrom::Start(4)).is_err()); + output.write_all(b"umi").unwrap(); writer.add_available(3); writer.finish();