koan_core/audio/streaming.rs
1//! `PartialFileSource` — a Read+Seek adapter over a file that is still downloading.
2//!
3//! The download thread writes the track to a `.part` file and publishes how far
4//! it has got in an `AtomicU64`; the Symphonia decoder reads the same file
5//! through a `PartialFileSource`, which blocks when the read position catches
6//! up to the write head. Playback starts long before the transfer finishes and
7//! seeking anywhere below the write head costs a `lseek`.
8//!
9//! Nothing is copied. An earlier design pumped the file into a shared `Vec<u8>`
10//! so the decoder could read from memory, which cost as much RAM as the track
11//! was long — half a gigabyte for a nine-hour recording, held for as long as it
12//! played. The bytes are already on disk; the page cache is better at this.
13//!
14//! The open descriptor survives the download's final rename from `.part` to its
15//! cache path, so a transfer landing mid-playback changes nothing for a reader.
16
17use std::fs::File;
18use std::io::{self, Read, Seek, SeekFrom};
19use std::path::Path;
20use std::sync::Arc;
21use std::sync::atomic::Ordering;
22use std::time::{Duration, Instant};
23
24/// Longest a read may block waiting for bytes before the transfer counts as
25/// dead. A download that stops advancing must surface as an error, not park the
26/// decode thread forever holding the ring buffer producer.
27const STALL_LIMIT: Duration = Duration::from_secs(30);
28
29/// Where a download has got to, as the source needs to know it.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum StreamStatus {
32 /// Bytes are still arriving.
33 Downloading,
34 /// Every byte landed. Reads past the end are a clean EOF.
35 Complete,
36 /// The transfer died before delivering everything. Reads past the written
37 /// bytes fail rather than reporting EOF, which would silently truncate the
38 /// track and look like a short file.
39 Failed,
40}
41
42/// A `Read + Seek` view of a file that is still being written.
43pub struct PartialFileSource {
44 file: File,
45 pos: u64,
46 /// How many bytes the download has committed to disk so far.
47 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
48 /// Total expected length, or 0 when the server sent no Content-Length.
49 total: u64,
50 status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
51 stall_limit: Duration,
52 /// Whether a read may wait at the write head for more of the download.
53 ///
54 /// Playback waits; probing does not. A container asked to describe itself
55 /// reads whatever it needs to, and Ogg needs its last page — so a probe
56 /// that waits waits for the whole transfer. Refusing instead turns that
57 /// into an immediate answer of "not from what has arrived", which is
58 /// something the caller can act on.
59 wait_for_bytes: bool,
60 /// Whether to state the advertised length.
61 ///
62 /// Saying nothing is what stops a container going looking for its tail:
63 /// Ogg reads its final page only when the source claims both a length and
64 /// seekability, and Symphonia scans for trailing metadata on the same
65 /// terms — which no partial file can satisfy. So this is what every
66 /// container mid-download ends up opened with, not only Ogg.
67 ///
68 /// It governs the end of the stream as well as the length, because they
69 /// have to be the same end. See `Seek`.
70 advertise_len: bool,
71 /// Whether `SeekFrom::End` answers for the whole file or for what has
72 /// arrived. Separate from `advertise_len` because Ogg needs the first
73 /// without the second — see `ProbeMode`.
74 whole_file_end: bool,
75}
76
77/// How much a probe may claim, and how far it may read.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ProbeMode {
80 /// State the length. A container that can describe itself from the bytes
81 /// already downloaded does, and the result is fully seekable.
82 Full,
83 /// State no length, so a container that would go looking for its tail
84 /// settles for what it can read from the front. Opens immediately, and
85 /// stays seekable within what has arrived for anything that describes its
86 /// frames from the front. What it gives up is whatever only the tail could
87 /// give — for Ogg that is the duration, and with it seeking at all.
88 ///
89 /// The end is what has arrived. FLAC bisects between its first frame and
90 /// the end it is given, so an end it cannot reach sends every probe into
91 /// bytes that are not on disk.
92 Lengthless,
93 /// The same, except that the end stays the whole file's.
94 ///
95 /// Ogg takes the end it is handed as the end of the *stream*. Handed the
96 /// write head it reports a track that is already over — nought
97 /// milliseconds — and the decode thread reaches the end of it in a second
98 /// and moves on to the next, over and over, so a large Opus file
99 /// downloading never plays at all. It cannot seek mid-download under either
100 /// answer; this is the difference between playing and not.
101 LengthlessWholeEnd,
102}
103
104impl PartialFileSource {
105 /// Open `path` for playback: reads wait at the write head for the download
106 /// to catch up. `bytes_written` is the download's own counter and `total`
107 /// its advertised length, 0 when it sent none.
108 ///
109 /// `mode` must be whatever the probe settled on. A container opened without
110 /// a length to describe itself has to be decoded without one too — given a
111 /// length it goes looking for its tail all over again, and this time on the
112 /// decode thread, where the cost is silence rather than a busy player.
113 pub fn open(
114 path: &Path,
115 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
116 total: u64,
117 status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
118 mode: ProbeMode,
119 ) -> io::Result<Self> {
120 let mut source = Self::with_stall_limit(path, bytes_written, total, status, STALL_LIMIT)?;
121 source.advertise_len = mode == ProbeMode::Full;
122 source.whole_file_end = mode != ProbeMode::Lengthless;
123 Ok(source)
124 }
125
126 /// Open for a probe: never waits at the write head, so a container that
127 /// cannot describe itself from what has arrived says so at once.
128 pub fn open_for_probe(
129 path: &Path,
130 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
131 total: u64,
132 status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
133 mode: ProbeMode,
134 ) -> io::Result<Self> {
135 let mut source = Self::with_stall_limit(path, bytes_written, total, status, STALL_LIMIT)?;
136 source.wait_for_bytes = false;
137 source.advertise_len = mode == ProbeMode::Full;
138 source.whole_file_end = mode != ProbeMode::Lengthless;
139 Ok(source)
140 }
141
142 fn with_stall_limit(
143 path: &Path,
144 bytes_written: Arc<crate::remote::downloads::ByteFeed>,
145 total: u64,
146 status: Arc<dyn Fn() -> StreamStatus + Send + Sync>,
147 stall_limit: Duration,
148 ) -> io::Result<Self> {
149 Ok(Self {
150 file: File::open(path)?,
151 pos: 0,
152 bytes_written,
153 total,
154 status,
155 stall_limit,
156 wait_for_bytes: true,
157 advertise_len: true,
158 whole_file_end: true,
159 })
160 }
161
162 /// Bytes known to be readable — what the download has written, or the whole
163 /// file once it has landed.
164 fn available(&self) -> u64 {
165 let written = self.bytes_written.load(Ordering::Acquire);
166 match (self.status)() {
167 StreamStatus::Complete => self.file.metadata().map(|m| m.len()).unwrap_or(written),
168 _ => written,
169 }
170 }
171
172 /// Read straight from the file, tolerating a short read at the write head:
173 /// Whatever ends a transfer must call `ByteFeed::done` when it sets the
174 /// status: a read waiting for bytes is parked on the feed, and a download
175 /// that failed has no more bytes to wake it with.
176 ///
177 /// `bytes_written` is published by the downloader as it goes and the data
178 /// behind it can lag by a moment.
179 fn read_available(&mut self, buf: &mut [u8], limit: u64) -> io::Result<usize> {
180 let to_read = (limit as usize).min(buf.len());
181 self.file.read(&mut buf[..to_read]).inspect(|n| {
182 self.pos += *n as u64;
183 })
184 }
185}
186
187impl Read for PartialFileSource {
188 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
189 if buf.is_empty() {
190 return Ok(0);
191 }
192
193 let deadline = Instant::now() + self.stall_limit;
194 loop {
195 let available = self.available();
196 if available > self.pos {
197 let n = self.read_available(buf, available - self.pos)?;
198 if n > 0 {
199 return Ok(n);
200 }
201 // The counter ran ahead of what is visible on disk. Fall
202 // through and wait rather than reporting a false EOF.
203 }
204
205 match (self.status)() {
206 StreamStatus::Failed => {
207 return Err(io::Error::new(
208 io::ErrorKind::BrokenPipe,
209 "stream download failed before delivering the whole track",
210 ));
211 }
212 // Everything landed and there is nothing past `pos`: real EOF.
213 StreamStatus::Complete if available <= self.pos => return Ok(0),
214 StreamStatus::Complete => {}
215 StreamStatus::Downloading => {
216 // A server that sent a Content-Length has delivered it all.
217 if self.total > 0 && available >= self.total && self.pos >= self.total {
218 return Ok(0);
219 }
220 }
221 }
222
223 if !self.wait_for_bytes {
224 return Err(io::Error::new(
225 io::ErrorKind::UnexpectedEof,
226 "past what the download has delivered",
227 ));
228 }
229 if Instant::now() >= deadline {
230 return Err(io::Error::new(
231 io::ErrorKind::TimedOut,
232 "stream download stalled",
233 ));
234 }
235 // Woken by the download itself, and by it finishing or failing.
236 // The deadline is the giving-up clock rather than a look-again
237 // one: this thread is not scheduled between one chunk and the next.
238 self.bytes_written.wait_past(available, deadline);
239 }
240 }
241}
242
243impl Seek for PartialFileSource {
244 fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
245 let target: i64 = match pos {
246 SeekFrom::Start(n) => n as i64,
247 SeekFrom::Current(n) => self.pos as i64 + n,
248 // The end has to be the same end `byte_len` describes. A source
249 // that states a length is asked about the whole file and answers
250 // for it; one that states none has only what has arrived, and
251 // answering with the advertised total there sends a reader
252 // bisecting into bytes that are not on disk yet — which is the
253 // whole file's worth of waiting for a FLAC seeked mid-download.
254 SeekFrom::End(n) => {
255 let len = if self.whole_file_end && self.total > 0 {
256 self.total
257 } else {
258 self.available()
259 };
260 len as i64 + n
261 }
262 };
263
264 if target < 0 {
265 return Err(io::Error::new(
266 io::ErrorKind::InvalidInput,
267 "seek before beginning of stream",
268 ));
269 }
270
271 self.pos = self.file.seek(SeekFrom::Start(target as u64))?;
272 Ok(self.pos)
273 }
274}
275
276// Symphonia requires MediaSource: Read + Seek + Send + Any
277impl symphonia::core::io::MediaSource for PartialFileSource {
278 fn is_seekable(&self) -> bool {
279 // Backward seeks and forward seeks below the write head are a `lseek`
280 // on a file that is already there. A forward seek past it lands on a
281 // read that blocks until the bytes arrive, which is the honest
282 // behaviour — callers clamp to `seekable_ms` to avoid asking.
283 //
284 // Saying no would cost more than it saved: a reader told a stream is
285 // unseekable does not stop seeking, it walks the whole file to the
286 // target instead, and cannot go backwards at all.
287 true
288 }
289
290 fn byte_len(&self) -> Option<u64> {
291 (self.advertise_len && self.total > 0).then_some(self.total)
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use std::io::Write;
298 use std::sync::atomic::AtomicU8;
299
300 use symphonia::core::io::MediaSource;
301
302 use super::*;
303
304 /// A file plus the counter and status a download would publish, so a test
305 /// can advance either independently.
306 struct Fixture {
307 _dir: tempfile::TempDir,
308 path: std::path::PathBuf,
309 written: Arc<crate::remote::downloads::ByteFeed>,
310 status: Arc<AtomicU8>,
311 }
312
313 const DOWNLOADING: u8 = 0;
314 const COMPLETE: u8 = 1;
315 const FAILED: u8 = 2;
316
317 impl Fixture {
318 fn new() -> Self {
319 let dir = tempfile::tempdir().unwrap();
320 let path = dir.path().join("track.opus.part");
321 File::create(&path).unwrap();
322 Self {
323 _dir: dir,
324 path,
325 written: crate::remote::downloads::ByteFeed::new(),
326 status: Arc::new(AtomicU8::new(DOWNLOADING)),
327 }
328 }
329
330 /// Append bytes and publish them, as the downloader does per chunk.
331 fn push(&self, chunk: &[u8]) {
332 let mut f = std::fs::OpenOptions::new()
333 .append(true)
334 .open(&self.path)
335 .unwrap();
336 f.write_all(chunk).unwrap();
337 f.flush().unwrap();
338 self.written.advance(chunk.len() as u64);
339 }
340
341 fn set(&self, status: u8) {
342 self.status.store(status, Ordering::Release);
343 // What ends a transfer says so, the way the downloader does — a
344 // reader blocked for bytes that will never come is waiting on the
345 // feed, not on the status.
346 self.written.done();
347 }
348
349 fn status_fn(&self) -> Arc<dyn Fn() -> StreamStatus + Send + Sync> {
350 let status = self.status.clone();
351 Arc::new(move || match status.load(Ordering::Acquire) {
352 COMPLETE => StreamStatus::Complete,
353 FAILED => StreamStatus::Failed,
354 _ => StreamStatus::Downloading,
355 })
356 }
357
358 fn source(&self, total: u64) -> PartialFileSource {
359 self.source_with_stall(total, STALL_LIMIT)
360 }
361
362 fn source_with_stall(&self, total: u64, stall: Duration) -> PartialFileSource {
363 let status = self.status.clone();
364 PartialFileSource::with_stall_limit(
365 &self.path,
366 self.written.clone(),
367 total,
368 Arc::new(move || match status.load(Ordering::Acquire) {
369 COMPLETE => StreamStatus::Complete,
370 FAILED => StreamStatus::Failed,
371 _ => StreamStatus::Downloading,
372 }),
373 stall,
374 )
375 .unwrap()
376 }
377 }
378
379 #[test]
380 fn reads_what_has_landed() {
381 let fx = Fixture::new();
382 fx.push(b"hello streaming world");
383 fx.set(COMPLETE);
384
385 let mut out = Vec::new();
386 fx.source(21).read_to_end(&mut out).unwrap();
387 assert_eq!(out, b"hello streaming world");
388 }
389
390 #[test]
391 fn read_stops_at_the_write_head_then_resumes() {
392 let fx = Fixture::new();
393 fx.push(b"abcd");
394 let mut src = fx.source(10);
395
396 let mut first = [0u8; 8];
397 assert_eq!(src.read(&mut first).unwrap(), 4);
398 assert_eq!(&first[..4], b"abcd");
399
400 // The rest arrives while the reader is blocked on it.
401 std::thread::spawn({
402 let path = fx.path.clone();
403 let written = fx.written.clone();
404 move || {
405 std::thread::sleep(Duration::from_millis(20));
406 let mut f = std::fs::OpenOptions::new()
407 .append(true)
408 .open(&path)
409 .unwrap();
410 f.write_all(b"efghij").unwrap();
411 f.flush().unwrap();
412 written.advance(6);
413 }
414 });
415
416 let mut rest = [0u8; 8];
417 let n = src.read(&mut rest).unwrap();
418 assert_eq!(&rest[..n], b"efghij");
419 }
420
421 #[test]
422 fn seeks_freely_below_the_write_head() {
423 let fx = Fixture::new();
424 fx.push(b"0123456789");
425 let mut src = fx.source(1_000_000);
426
427 assert_eq!(src.seek(SeekFrom::Start(5)).unwrap(), 5);
428 let mut out = [0u8; 3];
429 src.read_exact(&mut out).unwrap();
430 assert_eq!(&out, b"567");
431
432 // Backwards, into bytes already read — no re-download, no buffer.
433 assert_eq!(src.seek(SeekFrom::Start(1)).unwrap(), 1);
434 src.read_exact(&mut out).unwrap();
435 assert_eq!(&out, b"123");
436
437 assert_eq!(src.seek(SeekFrom::Current(-2)).unwrap(), 2);
438 }
439
440 #[test]
441 fn seek_from_end_uses_the_advertised_length() {
442 let fx = Fixture::new();
443 fx.push(b"0123456789");
444 let mut src = fx.source(10);
445
446 assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 10);
447 assert_eq!(src.seek(SeekFrom::End(-3)).unwrap(), 7);
448
449 let mut out = [0u8; 3];
450 src.read_exact(&mut out).unwrap();
451 assert_eq!(&out, b"789");
452 }
453
454 #[test]
455 fn a_lengthless_source_ends_where_the_download_does() {
456 // The end has to agree with `byte_len`. FLAC seeks by bisecting
457 // between its first frame and `SeekFrom::End(0)`, so answering with the
458 // advertised total aims the search at bytes that are not on disk yet:
459 // every probe of the range waits at the write head, and a seek into a
460 // half-downloaded track spends the stall limit before failing.
461 let fx = Fixture::new();
462 fx.push(b"0123456789");
463
464 let mut src = PartialFileSource::open(
465 &fx.path,
466 fx.written.clone(),
467 1_000,
468 fx.status_fn(),
469 ProbeMode::Lengthless,
470 )
471 .unwrap();
472 assert_eq!(src.byte_len(), None);
473 assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 10);
474
475 // Stating a length means answering for the whole of it.
476 let mut src = PartialFileSource::open(
477 &fx.path,
478 fx.written.clone(),
479 1_000,
480 fx.status_fn(),
481 ProbeMode::Full,
482 )
483 .unwrap();
484 assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 1_000);
485 }
486
487 /// Ogg's half of the same question, and the opposite answer.
488 ///
489 /// Handed the write head as the end, Ogg reports a stream that is already
490 /// over: a large Opus file downloading opened, said nought milliseconds,
491 /// and the decode thread finished it and moved on, over and over, so it
492 /// never played at all.
493 #[test]
494 fn an_ogg_source_still_ends_at_the_whole_file() {
495 let fx = Fixture::new();
496 fx.push(b"0123456789");
497 let mut src = PartialFileSource::open(
498 &fx.path,
499 fx.written.clone(),
500 1_000,
501 fx.status_fn(),
502 ProbeMode::LengthlessWholeEnd,
503 )
504 .unwrap();
505 // Still no length: the point of opening this way is that Ogg does not
506 // go looking for a tail that has not arrived.
507 assert_eq!(src.byte_len(), None);
508 // But the end it is told about is the file's, not the download's.
509 assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 1_000);
510 }
511
512 #[test]
513 fn a_landed_download_ends_at_the_whole_file() {
514 // The bound moves with the transfer, so it stops bounding anything
515 // once every byte is there.
516 let fx = Fixture::new();
517 fx.push(b"0123456789");
518 fx.set(COMPLETE);
519
520 let mut src = PartialFileSource::open(
521 &fx.path,
522 fx.written.clone(),
523 10,
524 fx.status_fn(),
525 ProbeMode::Lengthless,
526 )
527 .unwrap();
528 assert_eq!(src.seek(SeekFrom::End(0)).unwrap(), 10);
529 }
530
531 #[test]
532 fn seek_before_start_errors() {
533 let fx = Fixture::new();
534 fx.push(b"hello");
535 assert!(fx.source(5).seek(SeekFrom::Current(-1)).is_err());
536 }
537
538 #[test]
539 fn failed_download_errors_instead_of_reporting_eof() {
540 let fx = Fixture::new();
541 fx.push(b"partial");
542 fx.set(FAILED);
543 let mut src = fx.source(1000);
544
545 let mut out = [0u8; 7];
546 src.read_exact(&mut out).unwrap();
547 assert_eq!(&out, b"partial");
548
549 // Past the written bytes: an error, never a clean EOF — Ok(0) here
550 // would end the track early and look like a short file.
551 assert_eq!(
552 src.read(&mut out).unwrap_err().kind(),
553 io::ErrorKind::BrokenPipe
554 );
555 }
556
557 #[test]
558 fn failure_wakes_a_blocked_reader() {
559 let fx = Fixture::new();
560 let mut src = fx.source(1000);
561
562 let (status, written) = (fx.status.clone(), fx.written.clone());
563 std::thread::spawn(move || {
564 std::thread::sleep(Duration::from_millis(20));
565 status.store(FAILED, Ordering::Release);
566 written.done();
567 });
568
569 let mut out = [0u8; 8];
570 assert_eq!(
571 src.read(&mut out).unwrap_err().kind(),
572 io::ErrorKind::BrokenPipe
573 );
574 }
575
576 #[test]
577 fn a_probe_reads_only_what_has_arrived() {
578 // The first attempt must fail at the write head rather than wait: a
579 // container that would go looking for its tail has to be detected, not
580 // waited for.
581 let fx = Fixture::new();
582 fx.push(b"0123456789");
583 let mut src = PartialFileSource::open_for_probe(
584 &fx.path,
585 fx.written.clone(),
586 1_000,
587 fx.status_fn(),
588 ProbeMode::Full,
589 )
590 .unwrap();
591
592 let mut out = [0u8; 10];
593 src.read_exact(&mut out).unwrap();
594 assert_eq!(
595 src.read(&mut out).unwrap_err().kind(),
596 io::ErrorKind::UnexpectedEof,
597 "past the write head is an answer, not a wait"
598 );
599 }
600
601 #[test]
602 fn playback_reads_wait_for_what_has_not_arrived() {
603 // And the second attempt does wait, which is what lets a container
604 // whose audio starts further in than the streaming threshold — a FLAC
605 // with a large padding block, most of them — be opened at all.
606 let fx = Fixture::new();
607 fx.push(b"0123456789");
608 let mut src = PartialFileSource::open(
609 &fx.path,
610 fx.written.clone(),
611 1_000,
612 fx.status_fn(),
613 ProbeMode::Lengthless,
614 )
615 .unwrap();
616
617 let mut out = [0u8; 10];
618 src.read_exact(&mut out).unwrap();
619
620 std::thread::spawn({
621 let path = fx.path.clone();
622 let written = fx.written.clone();
623 move || {
624 std::thread::sleep(Duration::from_millis(20));
625 let mut f = std::fs::OpenOptions::new()
626 .append(true)
627 .open(&path)
628 .unwrap();
629 f.write_all(b"abcde").unwrap();
630 f.flush().unwrap();
631 written.advance(5);
632 }
633 });
634
635 let n = src.read(&mut out).unwrap();
636 assert_eq!(&out[..n], b"abcde", "it waited rather than giving up");
637 }
638
639 #[test]
640 fn stalled_download_times_out() {
641 // A download with a Content-Length that never arrives: the read must
642 // give up rather than park the decode thread forever.
643 let fx = Fixture::new();
644 let mut src = fx.source_with_stall(1000, Duration::from_millis(20));
645 let mut out = [0u8; 8];
646 assert_eq!(
647 src.read(&mut out).unwrap_err().kind(),
648 io::ErrorKind::TimedOut
649 );
650 }
651
652 #[test]
653 fn completion_ends_the_read_at_the_true_length() {
654 // A chunked transfer reports no total; completion is what says the file
655 // is whole, and its length on disk is what is readable.
656 let fx = Fixture::new();
657 fx.push(b"chunked");
658 fx.set(COMPLETE);
659
660 let mut out = Vec::new();
661 fx.source(0).read_to_end(&mut out).unwrap();
662 assert_eq!(out, b"chunked");
663 }
664
665 #[test]
666 fn survives_the_part_file_being_renamed() {
667 // The download's final act is a rename. A reader that already has the
668 // file open must not notice.
669 let fx = Fixture::new();
670 fx.push(b"0123456789");
671 let mut src = fx.source(10);
672
673 let mut out = [0u8; 4];
674 src.read_exact(&mut out).unwrap();
675 assert_eq!(&out, b"0123");
676
677 std::fs::rename(&fx.path, fx.path.with_extension("")).unwrap();
678 fx.set(COMPLETE);
679
680 let mut rest = Vec::new();
681 src.read_to_end(&mut rest).unwrap();
682 assert_eq!(rest, b"456789");
683 }
684
685 #[test]
686 fn byte_len_is_the_advertised_length_only() {
687 let fx = Fixture::new();
688 assert_eq!(fx.source(42).byte_len(), Some(42));
689 // No Content-Length: the length is genuinely unknown, and claiming one
690 // would have Symphonia compute a duration from it.
691 assert_eq!(fx.source(0).byte_len(), None);
692 }
693
694 #[test]
695 fn is_seekable_true() {
696 let fx = Fixture::new();
697 assert!(fx.source(0).is_seekable());
698 }
699}