1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::Mutex;
5
6use musefs_db::convert::usize_from;
7use musefs_db::{Db, Format};
8use musefs_format::flac::{self, MetadataBlock};
9use musefs_format::{BinaryTagInput, RegionLayout, Segment, mp3, mp4, wav};
10use quick_cache::Weighter;
11use quick_cache::sync::Cache;
12
13use crate::error::{CoreError, Result};
14use crate::facade::Mode;
15use crate::freshness::BackingStamp;
16use crate::mapping::{tags_to_inputs, track_art_to_inputs};
17use crate::ogg_index::serve_ogg_window;
18
19#[derive(Debug)]
22pub struct ResolvedFile {
23 pub layout: RegionLayout,
24 pub total_len: u64,
25 pub track_id: i64,
29 pub content_version: i64,
30 pub backing_path: PathBuf,
31 pub stamp: BackingStamp,
32 pub mtime_secs: i64,
33 pub last_page: Mutex<Option<(u64, u64, Vec<u8>)>>,
38 pub cache_bytes: u64,
41 pub streams_db_rowid: bool,
46}
47
48#[derive(Clone)]
53struct CacheBytesWeighter;
54
55impl Weighter<i64, Arc<ResolvedFile>> for CacheBytesWeighter {
56 fn weight(&self, _key: &i64, val: &Arc<ResolvedFile>) -> u64 {
57 val.cache_bytes.max(1)
58 }
59}
60
61pub struct HeaderCache {
65 cache: Cache<i64, Arc<ResolvedFile>, CacheBytesWeighter>,
66 mode: Mode,
67}
68
69pub const DEFAULT_CACHE_BUDGET: u64 = 64 * 1024 * 1024;
71
72const CACHE_ESTIMATED_ITEMS: usize = (DEFAULT_CACHE_BUDGET / 4096) as usize;
77
78fn read_front(path: &Path, n: u64) -> crate::Result<Vec<u8>> {
79 use std::io::Read;
80 if n > crate::scan::MAX_PROBE_BYTES {
85 return Err(CoreError::HeaderTooLarge {
86 requested: n,
87 cap: crate::scan::MAX_PROBE_BYTES,
88 });
89 }
90 crate::metrics::on_open();
91 let mut f = std::fs::File::open(path).map_err(|e| CoreError::backing_io(path, e))?;
92 let mut buf = vec![0u8; usize_from(n)];
93 f.read_exact(&mut buf)
94 .map_err(|e| CoreError::backing_io(path, e))?;
95 Ok(buf)
96}
97
98impl HeaderCache {
99 pub fn new(mode: Mode) -> HeaderCache {
100 HeaderCache::with_budget(mode, DEFAULT_CACHE_BUDGET)
101 }
102 pub fn with_budget(mode: Mode, budget: u64) -> HeaderCache {
103 HeaderCache {
104 cache: Cache::with_weighter(CACHE_ESTIMATED_ITEMS, budget, CacheBytesWeighter),
105 mode,
106 }
107 }
108 pub fn retain(&self, live: &HashSet<i64>) {
110 self.cache.retain(|id, _| live.contains(id));
111 }
112 pub fn remove(&self, id: i64) {
114 self.cache.remove(&id);
115 }
116 pub fn resolve<M>(&self, db: &Db<M>, track_id: i64) -> Result<Arc<ResolvedFile>> {
120 let track = db
121 .get_track(track_id)?
122 .ok_or(CoreError::TrackNotFound(track_id))?;
123
124 crate::metrics::on_stat();
127 let meta = std::fs::metadata(&track.backing_path)
128 .map_err(|e| CoreError::backing_io(&track.backing_path, e))?;
129 if BackingStamp::from_metadata(&meta) != BackingStamp::from_track(&track) {
130 return Err(CoreError::BackingChanged(track.backing_path.clone()));
131 }
132
133 if let Some(hit) = self.cache.get(&track_id)
134 && hit.content_version == track.content_version
135 {
136 return Ok(hit);
137 }
138 let resolved = self.build(db, &track, &meta)?;
139 self.cache.insert(track_id, resolved.clone());
140 Ok(resolved)
141 }
142 fn build<M>(
144 &self,
145 db: &Db<M>,
146 track: &musefs_db::Track,
147 meta: &std::fs::Metadata,
148 ) -> Result<Arc<ResolvedFile>> {
149 let (layout, total_len, mtime_secs_val) = match self.mode {
150 Mode::StructureOnly => {
151 let layout = RegionLayout::validated(vec![Segment::BackingAudio {
155 offset: 0,
156 len: meta.len(),
157 }])
158 .map_err(musefs_format::FormatError::InvalidLayout)?;
159 (
160 layout,
161 meta.len(),
162 BackingStamp::from_track(track).display_secs(),
163 )
164 }
165 Mode::Synthesis => {
166 if track
171 .bounds
172 .audio_offset()
173 .saturating_add(track.bounds.audio_length())
174 > meta.len()
175 {
176 return Err(CoreError::BackingChanged(track.backing_path.clone()));
177 }
178
179 let inputs = tags_to_inputs(db.get_tags(track.id)?);
180 let art_inputs = track_art_to_inputs(db, track.id)?;
181 let binary_tag_inputs = crate::mapping::binary_tags_to_inputs(db, track.id)?;
182
183 let layout = match track.format {
187 Format::Flac => {
188 let rows = db.get_structural_blocks(track.id)?;
189 let (structural, binary_tags): (Vec<MetadataBlock>, &[BinaryTagInput]) =
196 if rows.is_empty() {
197 let front = read_front(
198 Path::new(&track.backing_path),
199 track.bounds.audio_offset(),
200 )?;
201 (flac::read_metadata(&front)?.preserved, &[])
202 } else {
203 let structural = rows
204 .into_iter()
205 .filter_map(|b| {
206 flac::structural_block_type(&b.kind).map(|block_type| {
207 MetadataBlock {
208 block_type,
209 body: b.body,
210 }
211 })
212 })
213 .collect();
214 (structural, &binary_tag_inputs)
215 };
216 for key in invalid_vorbis_keys(&inputs) {
217 log::warn!(
218 "track {}: dropping tag key {key:?} from Vorbis \
219 synthesis (not a valid field name)",
220 track.id
221 );
222 }
223 flac::synthesize_layout(
224 &structural,
225 track.bounds.audio_offset(),
226 track.bounds.audio_length(),
227 &inputs,
228 binary_tags,
229 &art_inputs,
230 )?
231 }
232 Format::Mp3 => mp3::synthesize_layout(
233 track.bounds.audio_offset(),
234 track.bounds.audio_length(),
235 &inputs,
236 &binary_tag_inputs,
237 &art_inputs,
238 )?,
239 Format::M4a => {
240 let mut f = std::fs::File::open(&track.backing_path)
248 .map_err(|e| CoreError::backing_io(&track.backing_path, e))?;
249 let len = meta.len();
252 let scan = mp4::read_structure_from(&mut f, len).map_err(|e| match e {
253 mp4::Mp4ScanError::Io(io) => {
254 CoreError::backing_io(&track.backing_path, io)
255 }
256 mp4::Mp4ScanError::Format(fe) => CoreError::Format(fe),
257 mp4::Mp4ScanError::MetadataTooLarge {
263 box_kind,
264 size,
265 cap,
266 } => CoreError::Mp4MetadataTooLarge {
267 box_kind,
268 size,
269 cap,
270 },
271 })?;
272 mp4::synthesize_layout(&scan, &inputs, &binary_tag_inputs, &art_inputs)?
273 }
274 Format::Wav => {
275 let front = read_front(
278 Path::new(&track.backing_path),
279 track.bounds.audio_offset(),
280 )?;
281 let scan = wav::read_structure(&front)?;
282 wav::synthesize_layout(
283 &scan,
284 track.bounds.audio_offset(),
285 track.bounds.audio_length(),
286 &inputs,
287 &binary_tag_inputs,
288 &art_inputs,
289 )?
290 }
291 Format::Opus | Format::Vorbis | Format::OggFlac => {
292 let front = read_front(
293 Path::new(&track.backing_path),
294 track.bounds.audio_offset(),
295 )?;
296 let header = musefs_format::ogg::read_metadata(&front)?;
297 let arts: Vec<musefs_format::ogg::OggArt> = art_inputs
298 .iter()
299 .map(|meta| musefs_format::ogg::OggArt { meta })
300 .collect();
301 let src = crate::mapping::DbArtSource(db);
302 for key in invalid_vorbis_keys(&inputs) {
303 log::warn!(
304 "track {}: dropping tag key {key:?} from Vorbis \
305 synthesis (not a valid field name)",
306 track.id
307 );
308 }
309 musefs_format::ogg::synthesize_layout(
310 &header,
311 track.bounds.audio_offset(),
312 track.bounds.audio_length(),
313 &inputs,
314 &arts,
315 &src,
316 )?
317 }
318 };
319 let total = layout.total_len();
320 (
321 layout,
322 total,
323 BackingStamp::from_track(track)
324 .display_secs()
325 .max(track.updated_at),
326 )
327 }
328 };
329
330 layout
334 .validate()
335 .map_err(musefs_format::FormatError::InvalidLayout)?;
336
337 let cache_bytes = layout
338 .segments()
339 .iter()
340 .map(|s| match s {
341 Segment::Inline(b) => b.len() as u64,
342 _ => 0,
343 })
344 .sum::<u64>();
345 let streams_db_rowid = layout.streams_db_rowid();
346 Ok(Arc::new(ResolvedFile {
347 layout,
348 total_len,
349 track_id: track.id,
350 content_version: track.content_version,
351 backing_path: PathBuf::from(&track.backing_path),
362 stamp: BackingStamp::from_track(track),
363 mtime_secs: mtime_secs_val,
364 last_page: Mutex::new(None),
365 cache_bytes,
366 streams_db_rowid,
367 }))
368 }
369 pub fn entry_count(&self) -> u64 {
371 self.cache.len() as u64
372 }
373 pub fn weight_bytes(&self) -> u64 {
375 self.cache.weight()
376 }
377 pub fn budget_bytes(&self) -> u64 {
379 self.cache.capacity()
380 }
381 pub fn raw_hits(&self) -> u64 {
383 self.cache.hits()
384 }
385 pub fn raw_misses(&self) -> u64 {
387 self.cache.misses()
388 }
389}
390
391pub fn read_at_into<M>(
392 resolved: &ResolvedFile,
393 db: &Db<M>,
394 offset: u64,
395 size: u64,
396 out: &mut Vec<u8>,
397) -> Result<()> {
398 if offset >= resolved.total_len || size == 0 {
399 return Ok(());
400 }
401 let needs_file = resolved
402 .layout
403 .segments()
404 .iter()
405 .any(|s| matches!(s, Segment::BackingAudio { .. } | Segment::OggAudio { .. }));
406 let file = if needs_file {
413 crate::metrics::on_open();
414 let f = std::fs::File::open(&resolved.backing_path)
417 .map_err(|e| CoreError::backing_io(&resolved.backing_path, e))?;
418 let f_meta = f
419 .metadata()
420 .map_err(|e| CoreError::backing_io(&resolved.backing_path, e))?;
421 if BackingStamp::from_metadata(&f_meta) != resolved.stamp {
422 return Err(CoreError::BackingChanged(
423 resolved.backing_path.to_string_lossy().into_owned(),
424 ));
425 }
426 Some(f)
427 } else {
428 None
429 };
430
431 if resolved.streams_db_rowid {
436 db.begin_read()?;
437 let res = (|| {
438 if db.track_content_version(resolved.track_id)? != resolved.content_version {
439 return Err(CoreError::BackingChanged(
442 resolved.backing_path.to_string_lossy().into_owned(),
443 ));
444 }
445 read_with_optional_backing(resolved, db, file.as_ref(), offset, size, out)
446 })();
447 let _ = db.end_read(); res
449 } else {
450 read_with_optional_backing(resolved, db, file.as_ref(), offset, size, out)
451 }
452}
453
454fn read_with_optional_backing<M>(
458 resolved: &ResolvedFile,
459 db: &Db<M>,
460 file: Option<&std::fs::File>,
461 offset: u64,
462 size: u64,
463 out: &mut Vec<u8>,
464) -> Result<()> {
465 match file {
466 Some(file) => {
467 let pool = crate::readahead::ReadAheadPool::new(0);
468 let buf =
469 std::sync::Arc::new(std::sync::Mutex::new(crate::readahead::ReadAhead::new(0)));
470 let backing_len = resolved.stamp.size;
471 let epoch = std::sync::atomic::AtomicU64::new(0);
472 let br =
473 crate::readahead::BackingReader::new(file, &buf, &pool, 0, backing_len, &epoch);
474 read_segments_into(resolved, Some(db), Some(&br), offset, size, out)
475 }
476 None => read_segments_into(resolved, Some(db), None, offset, size, out),
477 }
478}
479
480fn invalid_vorbis_keys(inputs: &[musefs_format::TagInput]) -> Vec<&str> {
485 let mut seen = HashSet::new();
486 inputs
487 .iter()
488 .map(|t| t.key.as_str())
489 .filter(|k| !musefs_format::is_valid_vorbis_key(k))
490 .filter(|k| seen.insert(*k))
491 .collect()
492}
493
494pub fn read_at<M>(resolved: &ResolvedFile, db: &Db<M>, offset: u64, size: u64) -> Result<Vec<u8>> {
496 let mut out = Vec::new();
497 read_at_into(resolved, db, offset, size, &mut out)?;
498 Ok(out)
499}
500
501fn read_segments_into<M>(
508 resolved: &ResolvedFile,
509 db: Option<&Db<M>>,
510 backing: Option<&crate::readahead::BackingReader>,
511 offset: u64,
512 size: u64,
513 out: &mut Vec<u8>,
514) -> Result<()> {
515 if offset >= resolved.total_len || size == 0 {
516 return Ok(());
517 }
518 let end = offset.saturating_add(size).min(resolved.total_len);
519 out.reserve(usize_from(end - offset));
520
521 let mut seg_start = 0u64;
522 for seg in resolved.layout.segments() {
523 let seg_len = seg.len();
524 let seg_end = seg_start + seg_len;
525 let ov_start = offset.max(seg_start);
526 let ov_end = end.min(seg_end);
527 if ov_start < ov_end {
528 let within = ov_start - seg_start;
529 let n = usize_from(ov_end - ov_start);
530 match seg {
531 Segment::Inline(bytes) => {
532 let w = usize_from(within);
533 out.extend_from_slice(&bytes[w..w + n]);
534 }
535 Segment::BackingAudio { offset: bo, .. } => {
536 let br = backing.expect("backing segment requires an open backing reader");
537 let start = out.len();
538 out.resize(start + n, 0);
539 br.read_exact_at(&mut out[start..], bo + within)?;
540 }
541 Segment::ArtImage { art_id, .. } => {
542 let db = db.expect("art segment requires a DB connection");
543 let start = out.len();
544 out.resize(start + n, 0);
545 db.read_art_chunk_into(*art_id, within, &mut out[start..])?;
546 crate::metrics::on_art_chunk();
547 }
548 Segment::BinaryTag { payload_id, .. } => {
549 let db = db.expect("binary-tag segment requires a DB connection");
550 let start = out.len();
551 out.resize(start + n, 0);
552 db.read_binary_tag_chunk_into(*payload_id, within, &mut out[start..])?;
553 crate::metrics::on_binary_tag_chunk();
554 }
555 Segment::OggAudio {
556 offset: ao,
557 seq_delta,
558 len,
559 } => {
560 let br = backing.expect("ogg-audio segment requires an open backing reader");
561 serve_ogg_window(
562 br,
563 *ao,
564 *len,
565 *seq_delta,
566 within,
567 within + n as u64,
568 &mut *out,
569 Some(&resolved.last_page),
570 )?;
571 }
572 Segment::OggArtSlice {
573 art_id,
574 offset,
575 base64,
576 art_total,
577 ..
578 } => {
579 let db = db.expect("ogg-art segment requires a DB connection");
580 if *base64 {
581 let w =
582 musefs_format::ogg::b64_window(*offset + within, n as u64, *art_total);
583 let raw = db.read_art_chunk(*art_id, w.in_start, usize_from(w.in_len))?;
584 crate::metrics::on_art_chunk();
585 let slice = musefs_format::ogg::encode_b64_slice(&raw, w.skip, n)
586 .ok_or_else(|| {
587 CoreError::BackingChanged(format!(
588 "art {} shorter than its indexed base64 window",
589 *art_id
590 ))
591 })?;
592 out.extend_from_slice(&slice);
593 } else {
594 let start = out.len();
595 out.resize(start + n, 0);
596 db.read_art_chunk_into(*art_id, *offset + within, &mut out[start..])?;
597 crate::metrics::on_art_chunk();
598 }
599 }
600 }
601 }
602 seg_start = seg_end;
603 if seg_start >= end {
604 break;
605 }
606 }
607 Ok(())
608}
609
610pub fn read_at_with_file_into<M>(
614 resolved: &ResolvedFile,
615 db: Option<&Db<M>>,
616 backing: &crate::readahead::BackingReader,
617 offset: u64,
618 size: u64,
619 out: &mut Vec<u8>,
620) -> Result<()> {
621 read_segments_into(resolved, db, Some(backing), offset, size, out)
622}
623
624pub fn read_at_with_file<M>(
626 resolved: &ResolvedFile,
627 db: &Db<M>,
628 backing: &crate::readahead::BackingReader,
629 offset: u64,
630 size: u64,
631) -> Result<Vec<u8>> {
632 let mut out = Vec::new();
633 read_at_with_file_into(resolved, Some(db), backing, offset, size, &mut out)?;
634 Ok(out)
635}
636
637#[cfg(test)]
638mod ogg_serve_tests {
639 use super::*;
640 use musefs_format::Segment;
641 use musefs_format::ogg::page_test_support::lace_packet_pub;
642 use std::io::Write;
643
644 #[test]
645 fn read_at_renumbers_audio_and_preserves_payload() {
646 let (mut audio, _) = lace_packet_pub(0x99, 3, false, 10, &[0xA1u8; 200]);
648 let (a2, _) = lace_packet_pub(0x99, 4, false, 20, &vec![0xB2u8; 250]);
649 audio.extend_from_slice(&a2);
650 let audio_offset = 8u64;
651 let mut file_bytes = vec![0xFFu8; usize_from(audio_offset)];
652 file_bytes.extend_from_slice(&audio);
653
654 let dir = tempfile::tempdir().unwrap();
655 let path = dir.path().join("a.opus");
656 std::fs::File::create(&path)
657 .unwrap()
658 .write_all(&file_bytes)
659 .unwrap();
660
661 let layout = RegionLayout::validated(vec![
662 Segment::Inline(b"HDRBYTES".to_vec()), Segment::OggAudio {
664 offset: audio_offset,
665 len: audio.len() as u64,
666 seq_delta: 1, },
668 ])
669 .unwrap();
670 let total = layout.total_len();
671 let resolved = ResolvedFile {
672 layout,
673 total_len: total,
674 track_id: 1,
675 content_version: 0,
676 backing_path: path.clone(),
677 stamp: BackingStamp::from_metadata(&std::fs::metadata(&path).unwrap()),
680 mtime_secs: 0,
681 last_page: Mutex::new(None),
682 cache_bytes: 8,
683 streams_db_rowid: false,
684 };
685
686 let db = musefs_db::Db::open_in_memory().unwrap();
688 let got = read_at(&resolved, &db, 0, total).unwrap();
689 assert_eq!(got.len(), usize_from(total));
690 assert_eq!(&got[0..8], b"HDRBYTES");
691
692 let served_audio = &got[8..];
695 let h0 = musefs_format::ogg::parse_page(served_audio, 0).unwrap();
696 assert_eq!(h0.seq, 4);
697 let p1_off = h0.total_len();
698 let h1 = musefs_format::ogg::parse_page(served_audio, p1_off).unwrap();
699 assert_eq!(h1.seq, 5);
700 assert!(
702 served_audio[h0.header_len..h0.total_len()]
703 .iter()
704 .all(|&b| b == 0xA1)
705 );
706 assert!(
707 served_audio[p1_off + h1.header_len..p1_off + h1.total_len()]
708 .iter()
709 .all(|&b| b == 0xB2)
710 );
711 }
712}
713
714#[cfg(test)]
715mod resolve_ogg_tests {
716 use super::*;
717 use musefs_db::{Db, Format, NewTrack, Tag};
718 use musefs_format::ogg::page_test_support::lace_packet_pub;
719 use std::io::Write;
720 use std::os::unix::fs::MetadataExt;
721
722 fn build_opus_file(path: &std::path::Path) -> (u64, u64) {
723 let head = b"OpusHead\x01\x02\x38\x01\x80\xbb\x00\x00\x00\x00\x00".to_vec();
724 let mut tags = b"OpusTags".to_vec();
725 tags.extend_from_slice(&musefs_format::ogg::page_test_support::vorbis_body_empty());
726 let (mut bytes, pages) =
727 musefs_format::ogg::page_test_support::build_header_pub(0x1234, &[&head, &tags]);
728 let audio_offset = bytes.len() as u64;
729 let _ = pages;
730 let (audio, _) = lace_packet_pub(0x1234, 2, false, 960, &vec![0x7Eu8; 400]);
731 bytes.extend_from_slice(&audio);
732 std::fs::File::create(path)
733 .unwrap()
734 .write_all(&bytes)
735 .unwrap();
736 (audio_offset, bytes.len() as u64 - audio_offset)
737 }
738
739 #[test]
740 fn resolves_and_reads_opus_with_identical_audio() {
741 let dir = tempfile::tempdir().unwrap();
742 let path = dir.path().join("track.opus");
743 let (audio_offset, audio_length) = build_opus_file(&path);
744 let original = std::fs::read(&path).unwrap();
745
746 let db = Db::open_in_memory().unwrap();
747 let meta = std::fs::metadata(&path).unwrap();
748 let track_id = db
749 .upsert_track(&NewTrack {
750 backing_path: path.to_string_lossy().into_owned(),
751 format: Format::Opus,
752 audio_offset,
753 audio_length,
754 backing_size: meta.len(),
755 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
756 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
757 })
758 .unwrap();
759 db.replace_tags(track_id, &[Tag::new("title", "Telephasic Workshop", 0)])
760 .unwrap();
761
762 let cache = HeaderCache::new(Mode::Synthesis);
763 let resolved = cache.resolve(&db, track_id).unwrap();
764 let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
765
766 let header = musefs_format::ogg::read_header(&out).unwrap();
770 let synth_audio = &out[usize_from(header.audio_offset)..];
771 assert_eq!(synth_audio, &original[usize_from(audio_offset)..]);
772
773 let tags = musefs_format::ogg::read_tags(&out).unwrap();
776 assert!(
777 tags.iter()
778 .any(|(k, v)| k == "title" && v == "Telephasic Workshop")
779 );
780 }
781
782 #[test]
783 fn invalid_vorbis_keys_reports_distinct_out_of_grammar_keys() {
784 use musefs_format::TagInput;
785 let inputs = vec![
786 TagInput::new("artist", "A"),
787 TagInput::new("a=b", "c"),
788 TagInput::new("a=b", "d"), TagInput::new("title", "S"),
790 ];
791 assert_eq!(invalid_vorbis_keys(&inputs), vec!["a=b"]);
793 }
794
795 #[test]
796 fn synthesis_drops_invalid_vorbis_key_end_to_end() {
797 let dir = tempfile::tempdir().unwrap();
798 let path = dir.path().join("track.opus");
799 let (audio_offset, audio_length) = build_opus_file(&path);
800
801 let db = Db::open_in_memory().unwrap();
802 let meta = std::fs::metadata(&path).unwrap();
803 let track_id = db
804 .upsert_track(&NewTrack {
805 backing_path: path.to_string_lossy().into_owned(),
806 format: Format::Opus,
807 audio_offset,
808 audio_length,
809 backing_size: meta.len(),
810 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
811 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
812 })
813 .unwrap();
814 db.replace_tags(
817 track_id,
818 &[
819 Tag::new("artist", "Alice", 0),
820 Tag::new("a=b", "c", 0),
821 Tag::new("title", "Song", 0),
822 ],
823 )
824 .unwrap();
825
826 let cache = HeaderCache::new(Mode::Synthesis);
827 let resolved = cache.resolve(&db, track_id).unwrap();
828 let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
829
830 let tags = musefs_format::ogg::read_tags(&out).unwrap();
831 assert!(tags.iter().any(|(k, v)| k == "artist" && v == "Alice"));
832 assert!(tags.iter().any(|(k, v)| k == "title" && v == "Song"));
833 assert!(
834 !tags.iter().any(|(k, _)| k == "A" || k.contains('=')),
835 "the a=b key must be dropped, not synthesized as A=B=c: {tags:?}"
836 );
837 }
838
839 #[test]
840 fn read_at_with_file_matches_read_at() {
841 let dir = tempfile::tempdir().unwrap();
842 let path = dir.path().join("track.opus");
843 let (audio_offset, audio_length) = build_opus_file(&path);
844 let db = Db::open_in_memory().unwrap();
845 let meta = std::fs::metadata(&path).unwrap();
846 let track_id = db
847 .upsert_track(&NewTrack {
848 backing_path: path.to_string_lossy().into_owned(),
849 format: Format::Opus,
850 audio_offset,
851 audio_length,
852 backing_size: meta.len(),
853 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
854 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
855 })
856 .unwrap();
857 let cache = HeaderCache::new(Mode::Synthesis);
858 let resolved = cache.resolve(&db, track_id).unwrap();
859
860 let via_open = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
861 let file = std::fs::File::open(&resolved.backing_path).unwrap();
862 let pool = crate::readahead::ReadAheadPool::new(0);
863 let buf = Arc::new(Mutex::new(crate::readahead::ReadAhead::new(0)));
864 let epoch = std::sync::atomic::AtomicU64::new(0);
865 let br = crate::readahead::BackingReader::new(&file, &buf, &pool, 0, meta.len(), &epoch);
866 let via_file = read_at_with_file(&resolved, &db, &br, 0, resolved.total_len).unwrap();
867 assert_eq!(via_open, via_file);
868 }
869
870 fn build_wav_file(path: &std::path::Path) -> (u64, u64, Vec<u8>) {
871 use std::io::Write;
872 let mut fmt = Vec::new();
873 fmt.extend_from_slice(&1u16.to_le_bytes());
874 fmt.extend_from_slice(&1u16.to_le_bytes());
875 fmt.extend_from_slice(&44_100u32.to_le_bytes());
876 fmt.extend_from_slice(&88_200u32.to_le_bytes());
877 fmt.extend_from_slice(&2u16.to_le_bytes());
878 fmt.extend_from_slice(&16u16.to_le_bytes());
879
880 let data: Vec<u8> = (0..32u8).collect();
881 let mut body = Vec::new();
882 for (id, payload) in [(&b"fmt "[..], &fmt[..]), (&b"data"[..], &data[..])] {
883 body.extend_from_slice(id);
884 body.extend_from_slice(&u32::try_from(payload.len()).unwrap().to_le_bytes());
885 body.extend_from_slice(payload);
886 }
887 let mut bytes = b"RIFF".to_vec();
888 bytes.extend_from_slice(&u32::try_from(body.len() + 4).unwrap().to_le_bytes());
889 bytes.extend_from_slice(b"WAVE");
890 bytes.extend_from_slice(&body);
891
892 let audio_offset = (bytes.len() - data.len()) as u64;
893 std::fs::File::create(path)
894 .unwrap()
895 .write_all(&bytes)
896 .unwrap();
897 (audio_offset, data.len() as u64, data)
898 }
899
900 #[test]
901 fn resolves_and_reads_wav_with_identical_audio() {
902 let dir = tempfile::tempdir().unwrap();
903 let path = dir.path().join("track.wav");
904 let (audio_offset, audio_length, original_data) = build_wav_file(&path);
905
906 let db = Db::open_in_memory().unwrap();
907 let meta = std::fs::metadata(&path).unwrap();
908 let track_id = db
909 .upsert_track(&NewTrack {
910 backing_path: path.to_string_lossy().into_owned(),
911 format: Format::Wav,
912 audio_offset,
913 audio_length,
914 backing_size: meta.len(),
915 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
916 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
917 })
918 .unwrap();
919 db.replace_tags(track_id, &[Tag::new("title", "Wave One", 0)])
920 .unwrap();
921
922 let cache = HeaderCache::new(Mode::Synthesis);
923 let resolved = cache.resolve(&db, track_id).unwrap();
924 let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
925
926 let bounds = musefs_format::wav::locate_audio(&out).unwrap();
929 assert_eq!(
930 &out[usize_from(bounds.audio_offset)
931 ..usize_from(bounds.audio_offset + bounds.audio_length)],
932 original_data.as_slice()
933 );
934
935 let tags = musefs_format::wav::read_tags(&out);
937 assert!(tags.contains(&("title".to_string(), "Wave One".to_string())));
938 }
939
940 #[test]
941 fn build_cache_bytes_counts_inline_segments_for_ogg() {
942 use musefs_db::{Format, NewTrack};
943 let dir = tempfile::tempdir().unwrap();
944 let path = dir.path().join("a.opus");
945 let (audio_offset, audio_length) = build_opus_file(&path);
946 let db = musefs_db::Db::open_in_memory().unwrap();
947 let meta = std::fs::metadata(&path).unwrap();
948 let id = db
949 .upsert_track(&NewTrack {
950 backing_path: path.to_string_lossy().into_owned(),
951 format: Format::Opus,
952 audio_offset,
953 audio_length,
954 backing_size: meta.len(),
955 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
956 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
957 })
958 .unwrap();
959 let cache = HeaderCache::new(Mode::Synthesis);
960 let resolved = cache.resolve(&db, id).unwrap();
961 let inline_sum: u64 = resolved
962 .layout
963 .segments()
964 .iter()
965 .map(|s| match s {
966 Segment::Inline(b) => b.len() as u64,
967 _ => 0,
968 })
969 .sum();
970 assert_eq!(resolved.cache_bytes, inline_sum);
972 assert!(
973 inline_sum > 0,
974 "Opus header should have non-empty inline segments"
975 );
976 }
977}
978
979#[cfg(test)]
980mod ogg_art_serve_tests {
981 use super::*;
982
983 #[test]
984 fn read_at_serves_base64_art_slice_matching_full_encode() {
985 let image: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
986 let full_b64 = musefs_format::ogg::encode_b64_slice(
988 &image,
989 0,
990 usize_from(musefs_format::ogg::b64_len(image.len() as u64)),
991 )
992 .expect("full-length window lies within the encoded output");
993
994 let db = musefs_db::Db::open_in_memory().unwrap();
995 let art_id = db
996 .upsert_art(&musefs_db::NewArt {
997 mime: "image/png".to_string(),
998 width: Some(1),
999 height: Some(1),
1000 data: image.clone(),
1001 })
1002 .unwrap();
1003
1004 let layout = RegionLayout::validated(vec![
1005 Segment::Inline(b"HEAD".to_vec()),
1006 Segment::OggArtSlice {
1007 art_id,
1008 offset: 0,
1009 len: musefs_format::BlobLen::new(full_b64.len() as u64).unwrap(),
1010 base64: true,
1011 art_total: image.len() as u64,
1012 },
1013 Segment::Inline(b"XY".to_vec()),
1014 ])
1015 .unwrap();
1016 let total = layout.total_len();
1017 let resolved = ResolvedFile {
1018 layout,
1019 total_len: total,
1020 track_id: 1,
1021 content_version: 0,
1022 backing_path: std::path::PathBuf::from("/dev/null"),
1023 stamp: BackingStamp {
1024 size: 0,
1025 mtime_ns: 0,
1026 ctime_ns: 0,
1027 },
1028 mtime_secs: 0,
1029 last_page: Mutex::new(None),
1030 cache_bytes: 0,
1031 streams_db_rowid: false,
1032 };
1033
1034 let got = read_at(&resolved, &db, 0, total).unwrap();
1036 let mut want = b"HEAD".to_vec();
1037 want.extend_from_slice(&full_b64);
1038 want.extend_from_slice(b"XY");
1039 assert_eq!(got, want);
1040
1041 let part = read_at(&resolved, &db, 7, 23).unwrap();
1043 assert_eq!(part, want[7..30]);
1044 }
1045
1046 #[test]
1047 fn read_at_serves_raw_art_slice() {
1048 let image: Vec<u8> = (0..300u32)
1049 .map(|i| u8::try_from(i % 256).unwrap())
1050 .collect();
1051 let db = musefs_db::Db::open_in_memory().unwrap();
1052 let art_id = db
1053 .upsert_art(&musefs_db::NewArt {
1054 mime: "image/png".to_string(),
1055 width: None,
1056 height: None,
1057 data: image.clone(),
1058 })
1059 .unwrap();
1060 let layout = RegionLayout::validated(vec![Segment::OggArtSlice {
1061 art_id,
1062 offset: 0,
1063 len: musefs_format::BlobLen::new(image.len() as u64).unwrap(),
1064 base64: false,
1065 art_total: image.len() as u64,
1066 }])
1067 .unwrap();
1068 let total = layout.total_len();
1069 let resolved = ResolvedFile {
1070 layout,
1071 total_len: total,
1072 track_id: 1,
1073 content_version: 0,
1074 backing_path: std::path::PathBuf::from("/dev/null"),
1075 stamp: BackingStamp {
1076 size: 0,
1077 mtime_ns: 0,
1078 ctime_ns: 0,
1079 },
1080 mtime_secs: 0,
1081 last_page: Mutex::new(None),
1082 cache_bytes: 0,
1083 streams_db_rowid: false,
1084 };
1085 let got = read_at(&resolved, &db, 10, 50).unwrap();
1086 assert_eq!(got, image[10..60]);
1087 }
1088}
1089
1090#[cfg(test)]
1091mod cache_bound_tests {
1092 use super::*;
1093 use musefs_db::{Db, Format, NewTrack};
1094 use std::os::unix::fs::MetadataExt;
1095
1096 #[test]
1097 fn header_cache_exposes_budget_and_starts_empty() {
1098 let c = HeaderCache::with_budget(Mode::Synthesis, 1234);
1099 assert_eq!(c.entry_count(), 0);
1100 assert_eq!(c.weight_bytes(), 0);
1101 assert!(
1102 c.budget_bytes() >= 1234,
1103 "budget must be at least the requested amount"
1104 );
1105 }
1106
1107 #[test]
1108 fn header_cache_counts_entries_weight_hits_and_misses() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let db = Db::open_in_memory().unwrap();
1111 let mut ids = Vec::new();
1112 for name in ["a.flac", "b.flac"] {
1113 let path = dir.path().join(name);
1114 let (audio_offset, audio_length) = write_flac_local(&path);
1115 let meta = std::fs::metadata(&path).unwrap();
1116 ids.push(
1117 db.upsert_track(&NewTrack {
1118 backing_path: path.to_string_lossy().into_owned(),
1119 format: Format::Flac,
1120 audio_offset,
1121 audio_length,
1122 backing_size: meta.len(),
1123 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1124 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1125 })
1126 .unwrap(),
1127 );
1128 }
1129 let cache = HeaderCache::new(Mode::Synthesis);
1130 for id in &ids {
1131 cache.resolve(&db, *id).unwrap(); cache.resolve(&db, *id).unwrap(); }
1134 assert_eq!(cache.entry_count(), 2);
1135 assert_eq!(cache.raw_hits(), 2);
1136 assert_eq!(cache.raw_misses(), 2);
1137 assert!(
1138 cache.weight_bytes() > 0,
1139 "synthesis entries carry inline header bytes"
1140 );
1141 }
1142
1143 fn entry(content_version: i64, inline_len: usize) -> Arc<ResolvedFile> {
1144 Arc::new(ResolvedFile {
1145 layout: RegionLayout::new_unchecked(vec![Segment::Inline(vec![0u8; inline_len])]),
1146 total_len: inline_len as u64,
1147 track_id: 1,
1148 content_version,
1149 backing_path: std::path::PathBuf::from("/nonexistent"),
1150 stamp: BackingStamp {
1151 size: 0,
1152 mtime_ns: 0,
1153 ctime_ns: 0,
1154 },
1155 mtime_secs: 0,
1156 last_page: Mutex::new(None),
1157 cache_bytes: inline_len as u64,
1158 streams_db_rowid: false,
1159 })
1160 }
1161
1162 #[test]
1163 fn header_cache_resolve_caches_by_content_version() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let path = dir.path().join("a.flac");
1166 let (audio_offset, audio_length) = write_flac_local(&path);
1167 let db = Db::open_in_memory().unwrap();
1168 let meta = std::fs::metadata(&path).unwrap();
1169 let id = db
1170 .upsert_track(&NewTrack {
1171 backing_path: path.to_string_lossy().into_owned(),
1172 format: Format::Flac,
1173 audio_offset,
1174 audio_length,
1175 backing_size: meta.len(),
1176 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1177 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1178 })
1179 .unwrap();
1180 let cache = HeaderCache::new(Mode::Synthesis); let a = cache.resolve(&db, id).unwrap();
1182 let b = cache.resolve(&db, id).unwrap();
1183 assert!(Arc::ptr_eq(&a, &b));
1184 }
1185
1186 #[test]
1187 fn resolve_is_safe_under_concurrent_access() {
1188 let dir = tempfile::tempdir().unwrap();
1193 let flac_path = dir.path().join("a.flac");
1194 let (audio_offset, audio_length) = write_flac_local(&flac_path);
1195 let db_path = dir.path().join("m.db");
1196 let track_id = {
1197 let db = Db::open(&db_path).unwrap();
1198 let meta = std::fs::metadata(&flac_path).unwrap();
1199 db.upsert_track(&NewTrack {
1200 backing_path: flac_path.to_string_lossy().into_owned(),
1201 format: Format::Flac,
1202 audio_offset,
1203 audio_length,
1204 backing_size: meta.len(),
1205 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1206 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1207 })
1208 .unwrap()
1209 };
1210
1211 let cache = std::sync::Arc::new(HeaderCache::new(Mode::Synthesis));
1212 std::thread::scope(|s| {
1213 for _ in 0..8 {
1214 let cache = std::sync::Arc::clone(&cache);
1215 let db_path = db_path.clone();
1216 s.spawn(move || {
1217 let db = Db::open_readonly(&db_path).unwrap();
1218 for _ in 0..50 {
1219 let r = cache.resolve(&db, track_id).unwrap();
1220 assert!(r.total_len > 0);
1221 assert_eq!(r.content_version, 0);
1222 }
1223 });
1224 }
1225 });
1226 }
1227
1228 #[test]
1229 fn header_cache_retain_drops_absent_tracks() {
1230 let dir = tempfile::tempdir().unwrap();
1231 let db = Db::open_in_memory().unwrap();
1232 let mk = |name: &str| {
1233 let path = dir.path().join(name);
1234 let (audio_offset, audio_length) = write_flac_local(&path);
1235 let meta = std::fs::metadata(&path).unwrap();
1236 db.upsert_track(&NewTrack {
1237 backing_path: path.to_string_lossy().into_owned(),
1238 format: Format::Flac,
1239 audio_offset,
1240 audio_length,
1241 backing_size: meta.len(),
1242 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1243 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1244 })
1245 .unwrap()
1246 };
1247 let keep = mk("keep.flac");
1248 let gone = mk("gone.flac");
1249 let cache = HeaderCache::new(Mode::Synthesis);
1250 let keep_a = cache.resolve(&db, keep).unwrap();
1251 let gone_a = cache.resolve(&db, gone).unwrap();
1252
1253 let live: HashSet<i64> = [keep].into_iter().collect();
1254 cache.retain(&live);
1255
1256 assert!(Arc::ptr_eq(&keep_a, &cache.resolve(&db, keep).unwrap()));
1258 assert!(!Arc::ptr_eq(&gone_a, &cache.resolve(&db, gone).unwrap()));
1259 }
1260
1261 #[test]
1262 fn header_cache_remove_drops_one_track_only() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let db = Db::open_in_memory().unwrap();
1265 let mk = |name: &str| {
1266 let path = dir.path().join(name);
1267 let (audio_offset, audio_length) = write_flac_local(&path);
1268 let meta = std::fs::metadata(&path).unwrap();
1269 db.upsert_track(&NewTrack {
1270 backing_path: path.to_string_lossy().into_owned(),
1271 format: Format::Flac,
1272 audio_offset,
1273 audio_length,
1274 backing_size: meta.len(),
1275 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1276 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1277 })
1278 .unwrap()
1279 };
1280 let keep = mk("keep.flac");
1281 let gone = mk("gone.flac");
1282 let cache = HeaderCache::new(Mode::Synthesis);
1283 let keep_a = cache.resolve(&db, keep).unwrap();
1284 let gone_a = cache.resolve(&db, gone).unwrap();
1285
1286 cache.remove(gone);
1287
1288 assert!(Arc::ptr_eq(&keep_a, &cache.resolve(&db, keep).unwrap()));
1290 assert!(!Arc::ptr_eq(&gone_a, &cache.resolve(&db, gone).unwrap()));
1291 }
1292
1293 #[test]
1294 fn default_cache_budget_is_64_mib() {
1295 assert_eq!(DEFAULT_CACHE_BUDGET, 67_108_864);
1296 }
1297
1298 #[test]
1299 fn read_segments_returns_empty_past_end_of_range() {
1300 let db = musefs_db::Db::open_in_memory().unwrap();
1301 let resolved = entry(0, 10);
1302 let out = read_at(&resolved, &db, 11, 1).unwrap();
1303 assert!(out.is_empty());
1304 let out0 = read_at(&resolved, &db, 0, 0).unwrap();
1305 assert!(out0.is_empty());
1306 }
1307
1308 fn track_with_bounds(
1309 path: &std::path::Path,
1310 audio_offset: u64,
1311 audio_length: u64,
1312 ) -> (musefs_db::Db, i64) {
1313 use musefs_db::{Format, NewTrack};
1314 let db = musefs_db::Db::open_in_memory().unwrap();
1315 let meta = std::fs::metadata(path).unwrap();
1316 let id = db
1317 .upsert_track(&NewTrack {
1318 backing_path: path.to_string_lossy().into_owned(),
1319 format: Format::Flac,
1320 audio_offset,
1321 audio_length,
1322 backing_size: meta.len(),
1323 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1324 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1325 })
1326 .unwrap();
1327 (db, id)
1328 }
1329
1330 #[test]
1331 fn build_rejects_audio_region_past_end_of_file() {
1332 let dir = tempfile::tempdir().unwrap();
1336 let path = dir.path().join("a.flac");
1337 let _ = write_flac_local(&path);
1338 let meta = std::fs::metadata(&path).unwrap();
1339 let db = musefs_db::Db::open_in_memory().unwrap();
1340 let rejected = db.upsert_track(&musefs_db::NewTrack {
1341 backing_path: path.to_string_lossy().into_owned(),
1342 format: musefs_db::Format::Flac,
1343 audio_offset: meta.len(),
1344 audio_length: 5,
1345 backing_size: meta.len(),
1346 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1347 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1348 });
1349 assert!(
1350 rejected.is_err(),
1351 "bounds CHECK must reject an over-EOF audio region"
1352 );
1353 }
1354
1355 #[test]
1356 fn build_accepts_audio_region_ending_exactly_at_eof() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let path = dir.path().join("a.flac");
1359 let (audio_offset, audio_length) = write_flac_local(&path);
1360 let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1361 let cache = HeaderCache::new(Mode::Synthesis);
1362 let resolved = cache
1363 .resolve(&db, id)
1364 .expect("exact-fit bounds must resolve");
1365 assert!(resolved.total_len > 0);
1366 }
1367
1368 #[test]
1369 fn build_accepts_audio_region_ending_before_eof() {
1370 let dir = tempfile::tempdir().unwrap();
1376 let path = dir.path().join("a.flac");
1377 let (audio_offset, audio_length) = write_flac_local(&path);
1378 use std::io::Write;
1381 std::fs::OpenOptions::new()
1382 .append(true)
1383 .open(&path)
1384 .unwrap()
1385 .write_all(&[0u8; 64])
1386 .unwrap();
1387 let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1388 let cache = HeaderCache::new(Mode::Synthesis);
1389 let resolved = cache.resolve(&db, id).expect("sub-EOF bounds must resolve");
1390 assert!(resolved.total_len > 0);
1391 }
1392
1393 #[test]
1394 fn build_cache_bytes_counts_inline_segments() {
1395 let dir = tempfile::tempdir().unwrap();
1396 let path = dir.path().join("a.flac");
1397 let (audio_offset, audio_length) = write_flac_local(&path);
1398 let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1399 let cache = HeaderCache::new(Mode::Synthesis);
1400 let resolved = cache.resolve(&db, id).unwrap();
1401 let inline_sum: u64 = resolved
1402 .layout
1403 .segments()
1404 .iter()
1405 .map(|s| match s {
1406 Segment::Inline(b) => b.len() as u64,
1407 _ => 0,
1408 })
1409 .sum();
1410 assert!(inline_sum > 0);
1411 assert_eq!(resolved.cache_bytes, inline_sum);
1412 }
1413
1414 #[test]
1415 fn build_rejects_layout_failing_validation() {
1416 let bad = RegionLayout::new_unchecked(vec![Segment::Inline(vec![])]);
1419 let err = bad.validate();
1420 assert!(err.is_err());
1421 }
1422
1423 fn write_flac_local(path: &std::path::Path) -> (u64, u64) {
1424 fn block(bt: u8, body: &[u8], last: bool) -> Vec<u8> {
1425 let mut v = vec![(if last { 0x80 } else { 0 }) | (bt & 0x7F)];
1426 let n: u32 = u32::try_from(body.len()).unwrap();
1427 v.extend_from_slice(&[
1428 u8::try_from(n >> 16).unwrap(),
1429 u8::try_from(n >> 8).unwrap(),
1430 u8::try_from(n).unwrap(),
1431 ]);
1432 v.extend_from_slice(body);
1433 v
1434 }
1435 let mut si = vec![
1436 0x10, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A, 0xC4, 0x42, 0xF0,
1437 0x00, 0x00, 0x00, 0x00,
1438 ];
1439 si.extend_from_slice(&[0u8; 16]);
1440 let mut vc = Vec::new();
1441 let vendor = b"x";
1442 vc.extend_from_slice(&u32::try_from(vendor.len()).unwrap().to_le_bytes());
1443 vc.extend_from_slice(vendor);
1444 vc.extend_from_slice(&0u32.to_le_bytes());
1445 let mut out = b"fLaC".to_vec();
1446 out.extend(block(0, &si, false));
1447 out.extend(block(4, &vc, true));
1448 let audio = [0xABu8; 256];
1449 let audio_offset = out.len() as u64;
1450 out.extend_from_slice(&audio);
1451 std::fs::write(path, &out).unwrap();
1452 (audio_offset, audio.len() as u64)
1453 }
1454
1455 #[test]
1456 fn cache_weight_stays_within_budget_after_flood() {
1457 let cache = HeaderCache::with_budget(Mode::Synthesis, 4096);
1458 for id in 0..64i64 {
1459 cache.cache.insert(id, entry(0, 256)); }
1461 assert!(
1464 cache.cache.weight() <= 4096,
1465 "total weight {} exceeds the 4096-byte budget",
1466 cache.cache.weight()
1467 );
1468 assert!(
1472 cache.cache.len() < 64,
1473 "no eviction happened: all 64 over-budget entries are resident"
1474 );
1475 }
1476
1477 #[test]
1478 fn zero_cache_bytes_entry_still_weighs_one() {
1479 let cache = HeaderCache::with_budget(Mode::StructureOnly, 1024);
1483 cache.cache.insert(1, entry(0, 0));
1484 assert_eq!(cache.cache.weight(), 1);
1485 assert!(cache.cache.get(&1).is_some());
1486 }
1487}
1488
1489#[cfg(test)]
1490mod binary_tag_serve_tests {
1491 use super::*;
1492 use musefs_db::{BinaryTag, NewTrack};
1493 use std::os::unix::fs::MetadataExt;
1494
1495 #[test]
1496 fn resolve_mp3_emits_binary_tag_in_synthesized_region() {
1497 use id3::frame::{Content, Unknown};
1498 use id3::{Encoder, Frame, Tag, TagLike, Version};
1499 let dir = tempfile::tempdir().unwrap();
1500 let mut tag = Tag::new();
1501 let needle = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x77, 0x88];
1502 tag.add_frame(Frame::with_content(
1503 "PRIV",
1504 Content::Unknown(Unknown {
1505 data: needle.to_vec(),
1506 version: Version::Id3v24,
1507 }),
1508 ));
1509 let mut bytes = Vec::new();
1510 Encoder::new()
1511 .version(Version::Id3v24)
1512 .encode(&tag, &mut bytes)
1513 .unwrap();
1514 bytes.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
1515 let path = dir.path().join("a.mp3");
1516 std::fs::write(&path, &bytes).unwrap();
1517
1518 let db = musefs_db::Db::open_in_memory().unwrap();
1519 let bounds = musefs_format::mp3::locate_audio(&bytes).unwrap();
1520 let meta = std::fs::metadata(&path).unwrap();
1521 let tid = db
1522 .upsert_track(&musefs_db::NewTrack {
1523 backing_path: path.to_string_lossy().into_owned(),
1524 format: musefs_db::Format::Mp3,
1525 audio_offset: bounds.audio_offset,
1526 audio_length: bounds.audio_length,
1527 backing_size: meta.len(),
1528 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1529 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1530 })
1531 .unwrap();
1532 db.set_binary_tags(
1533 tid,
1534 &[musefs_db::BinaryTag {
1535 key: "PRIV".into(),
1536 payload: needle.to_vec(),
1537 ordinal: 0,
1538 }],
1539 )
1540 .unwrap();
1541
1542 let cache = crate::reader::HeaderCache::new(crate::Mode::Synthesis);
1543 let resolved = cache.resolve(&db, tid).unwrap();
1544 let whole = crate::reader::read_at(&resolved, &db, 0, resolved.total_len).unwrap();
1545 assert!(
1546 whole.windows(needle.len()).any(|w| w == needle),
1547 "PRIV body not in synthesized file"
1548 );
1549 }
1550
1551 #[test]
1552 fn read_at_serves_binary_tag_segment() {
1553 let db = Db::open_in_memory().unwrap();
1554 let id = db
1555 .upsert_track(&NewTrack {
1556 backing_path: "/x.mp3".into(),
1557 format: Format::Mp3,
1558 audio_offset: 0,
1559 audio_length: 0,
1560 backing_size: 0,
1561 backing_mtime_ns: 0,
1562 backing_ctime_ns: 0,
1563 })
1564 .unwrap();
1565 db.set_binary_tags(
1566 id,
1567 &[BinaryTag {
1568 key: "PRIV".into(),
1569 payload: vec![10, 20, 30, 40],
1570 ordinal: 0,
1571 }],
1572 )
1573 .unwrap();
1574 let rowid = db.get_binary_tags(id).unwrap()[0].rowid;
1575
1576 let resolved = ResolvedFile {
1577 layout: RegionLayout::validated(vec![Segment::BinaryTag {
1578 payload_id: rowid,
1579 len: musefs_format::BlobLen::new(4).unwrap(),
1580 }])
1581 .unwrap(),
1582 total_len: 4,
1583 track_id: id,
1584 content_version: db.track_content_version(id).unwrap(),
1587 backing_path: PathBuf::from("/x.mp3"),
1588 stamp: BackingStamp {
1589 size: 0,
1590 mtime_ns: 0,
1591 ctime_ns: 0,
1592 },
1593 mtime_secs: 0,
1594 last_page: Mutex::new(None),
1595 cache_bytes: 0,
1596 streams_db_rowid: true,
1597 };
1598 let got = read_at(&resolved, &db, 1, 2).unwrap();
1600 assert_eq!(got, vec![20, 30]);
1601 }
1602
1603 #[test]
1604 fn fallback_read_rejects_changed_backing() {
1605 let dir = tempfile::tempdir().unwrap();
1608 let path = dir.path().join("a.mp3");
1609 std::fs::write(&path, vec![0u8; 100]).unwrap();
1610 let db = Db::open_in_memory().unwrap();
1611 let layout = RegionLayout::validated(vec![
1612 Segment::Inline(vec![1, 2, 3]),
1613 Segment::BackingAudio {
1614 offset: 0,
1615 len: 100,
1616 },
1617 ])
1618 .unwrap();
1619 let total = layout.total_len();
1620 let resolved = ResolvedFile {
1621 layout,
1622 total_len: total,
1623 track_id: 1,
1624 content_version: 0,
1625 backing_path: path.clone(),
1626 stamp: BackingStamp::from_metadata(&std::fs::metadata(&path).unwrap()),
1627 mtime_secs: 0,
1628 last_page: Mutex::new(None),
1629 cache_bytes: 3,
1630 streams_db_rowid: false,
1631 };
1632 assert!(read_at(&resolved, &db, 0, total).is_ok());
1634 std::fs::write(&path, vec![0u8; 200]).unwrap();
1636 let err = read_at(&resolved, &db, 0, total).unwrap_err();
1637 assert!(matches!(err, CoreError::BackingChanged(_)), "{err:?}");
1638 }
1639
1640 #[test]
1641 fn fallback_read_of_art_rechecks_content_version() {
1642 let db = Db::open_in_memory().unwrap();
1646 let id = db
1647 .upsert_track(&NewTrack {
1648 backing_path: "/y.mp3".into(),
1649 format: Format::Mp3,
1650 audio_offset: 0,
1651 audio_length: 0,
1652 backing_size: 0,
1653 backing_mtime_ns: 0,
1654 backing_ctime_ns: 0,
1655 })
1656 .unwrap();
1657 let art_id = db
1658 .upsert_art(&musefs_db::NewArt {
1659 mime: "image/png".into(),
1660 width: None,
1661 height: None,
1662 data: vec![1, 2, 3, 4],
1663 })
1664 .unwrap();
1665 let layout = RegionLayout::validated(vec![Segment::ArtImage {
1666 art_id,
1667 len: musefs_format::BlobLen::new(4).unwrap(),
1668 }])
1669 .unwrap();
1670 let live_cv = db.track_content_version(id).unwrap();
1671 let mk = |content_version| ResolvedFile {
1672 layout: layout.clone(),
1673 total_len: 4,
1674 track_id: id,
1675 content_version,
1676 backing_path: PathBuf::from("/y.mp3"),
1677 stamp: BackingStamp {
1678 size: 0,
1679 mtime_ns: 0,
1680 ctime_ns: 0,
1681 },
1682 mtime_secs: 0,
1683 last_page: Mutex::new(None),
1684 cache_bytes: 0,
1685 streams_db_rowid: true,
1686 };
1687 assert_eq!(read_at(&mk(live_cv), &db, 0, 4).unwrap(), vec![1, 2, 3, 4]);
1689 let err = read_at(&mk(live_cv + 1), &db, 0, 4).unwrap_err();
1691 assert!(matches!(err, CoreError::BackingChanged(_)), "{err:?}");
1692 }
1693}
1694
1695#[cfg(test)]
1696mod serve_cap_tests {
1697 use super::*;
1698 use musefs_db::{Db, Format, NewTrack};
1699
1700 const CAP: u64 = crate::scan::MAX_PROBE_BYTES;
1701
1702 fn sparse_file(dir: &std::path::Path, name: &str, len: u64) -> std::path::PathBuf {
1705 let path = dir.join(name);
1706 let f = std::fs::File::create(&path).unwrap();
1707 f.set_len(len).unwrap();
1708 path
1709 }
1710
1711 fn hostile_track(db: &Db, path: &std::path::Path, format: Format) -> i64 {
1717 let meta = std::fs::metadata(path).unwrap();
1718 let stamp = BackingStamp::from_metadata(&meta);
1719 db.upsert_track(&NewTrack {
1720 backing_path: path.to_string_lossy().into_owned(),
1721 format,
1722 audio_offset: CAP + 1,
1723 audio_length: 1,
1724 backing_size: meta.len(),
1725 backing_mtime_ns: stamp.mtime_ns,
1726 backing_ctime_ns: stamp.ctime_ns,
1727 })
1728 .unwrap()
1729 }
1730
1731 fn assert_capped(result: crate::Result<std::sync::Arc<ResolvedFile>>) {
1733 match result {
1734 Err(CoreError::HeaderTooLarge { requested, cap }) => {
1735 assert_eq!(requested, CAP + 1);
1736 assert_eq!(cap, CAP);
1737 }
1738 Err(other) => panic!("expected HeaderTooLarge, got {other:?}"),
1739 Ok(_) => panic!("expected HeaderTooLarge, resolve unexpectedly succeeded"),
1740 }
1741 }
1742
1743 #[test]
1744 fn wav_serve_caps_hostile_offset() {
1745 let dir = tempfile::tempdir().unwrap();
1746 let path = sparse_file(dir.path(), "hostile.wav", CAP + 2);
1747 let db = Db::open_in_memory().unwrap();
1748 let track_id = hostile_track(&db, &path, Format::Wav);
1749
1750 let cache = HeaderCache::new(Mode::Synthesis);
1751 assert_capped(cache.resolve(&db, track_id));
1752 }
1753
1754 #[test]
1755 fn ogg_serve_caps_hostile_offset() {
1756 let dir = tempfile::tempdir().unwrap();
1757 let path = sparse_file(dir.path(), "hostile.opus", CAP + 2);
1758 let db = Db::open_in_memory().unwrap();
1759 let track_id = hostile_track(&db, &path, Format::Opus);
1760
1761 let cache = HeaderCache::new(Mode::Synthesis);
1762 assert_capped(cache.resolve(&db, track_id));
1763 }
1764
1765 #[test]
1766 fn flac_legacy_serve_caps_hostile_offset() {
1767 let dir = tempfile::tempdir().unwrap();
1768 let path = sparse_file(dir.path(), "hostile.flac", CAP + 2);
1769 let db = Db::open_in_memory().unwrap();
1770 let track_id = hostile_track(&db, &path, Format::Flac);
1773 assert!(db.get_structural_blocks(track_id).unwrap().is_empty());
1774
1775 let cache = HeaderCache::new(Mode::Synthesis);
1776 assert_capped(cache.resolve(&db, track_id));
1777 }
1778
1779 #[test]
1780 fn read_front_rejects_oversize_before_open() {
1781 let err =
1785 read_front(std::path::Path::new("/nonexistent/musefs/front"), CAP + 1).unwrap_err();
1786 match err {
1787 CoreError::HeaderTooLarge { requested, cap } => {
1788 assert_eq!(requested, CAP + 1);
1789 assert_eq!(cap, CAP);
1790 }
1791 other => panic!("expected HeaderTooLarge, got {other:?}"),
1792 }
1793 }
1794
1795 #[test]
1796 fn read_front_allows_exactly_cap() {
1797 let err = read_front(std::path::Path::new("/nonexistent/musefs/front"), CAP).unwrap_err();
1802 assert!(
1803 matches!(err, CoreError::BackingIo { .. }),
1804 "expected a backing-file Io error at the cap boundary, got {err:?}"
1805 );
1806 }
1807
1808 #[test]
1809 fn read_front_io_error_carries_the_backing_path() {
1810 let p = std::path::Path::new("/nonexistent/musefs/backing.flac");
1813 match read_front(p, 16).unwrap_err() {
1814 CoreError::BackingIo { path, .. } => assert_eq!(path, p),
1815 other => panic!("expected BackingIo carrying the path, got {other:?}"),
1816 }
1817 }
1818}
1819
1820#[cfg(test)]
1821mod readahead_differential_tests {
1822 use super::*;
1823 use crate::readahead::{BackingReader, ReadAhead, ReadAheadPool};
1824 use std::sync::{Arc, Mutex};
1825
1826 fn pcm_fixture() -> (musefs_db::Db, Arc<ResolvedFile>, std::fs::File) {
1827 let dir = tempfile::tempdir().unwrap();
1828 let path = dir.path().join("test.wav");
1829 let mut body = Vec::new();
1830 body.extend_from_slice(b"fmt ");
1831 body.extend_from_slice(&16u32.to_le_bytes());
1832 body.extend_from_slice(&1u16.to_le_bytes());
1833 body.extend_from_slice(&1u16.to_le_bytes());
1834 body.extend_from_slice(&44100u32.to_le_bytes());
1835 body.extend_from_slice(&88200u32.to_le_bytes());
1836 body.extend_from_slice(&2u16.to_le_bytes());
1837 body.extend_from_slice(&16u16.to_le_bytes());
1838 let audio_data: Vec<u8> = (0..1024u32).map(|i| (i % 251) as u8).collect();
1839 body.extend_from_slice(b"data");
1840 body.extend_from_slice(&u32::try_from(audio_data.len()).unwrap().to_le_bytes());
1841 body.extend_from_slice(&audio_data);
1842 let mut riff = b"RIFF".to_vec();
1843 riff.extend_from_slice(&u32::try_from(body.len()).unwrap().to_le_bytes());
1844 riff.extend_from_slice(b"WAVE");
1845 riff.extend_from_slice(&body);
1846 let audio_offset = (riff.len() - audio_data.len()) as u64;
1847 std::fs::write(&path, &riff).unwrap();
1848
1849 let db = musefs_db::Db::open_in_memory().unwrap();
1850 let meta = std::fs::metadata(&path).unwrap();
1851 use std::os::unix::fs::MetadataExt;
1852 let track_id = db
1853 .upsert_track(&musefs_db::NewTrack {
1854 backing_path: path.to_string_lossy().into_owned(),
1855 format: musefs_db::Format::Wav,
1856 audio_offset,
1857 audio_length: audio_data.len() as u64,
1858 backing_size: meta.len(),
1859 backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1860 backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1861 })
1862 .unwrap();
1863 let cache = HeaderCache::new(Mode::Synthesis);
1864 let resolved = cache.resolve(&db, track_id).unwrap();
1865 let file = std::fs::File::open(&resolved.backing_path).unwrap();
1866 (db, resolved, file)
1867 }
1868
1869 fn oracle_read(
1870 resolved: &ResolvedFile,
1871 file: &std::fs::File,
1872 offset: u64,
1873 size: u64,
1874 out: &mut Vec<u8>,
1875 ) -> Result<()> {
1876 if offset >= resolved.total_len || size == 0 {
1877 return Ok(());
1878 }
1879 let end = offset.saturating_add(size).min(resolved.total_len);
1880 out.reserve(usize_from(end - offset));
1881 let mut seg_start = 0u64;
1882 for seg in resolved.layout.segments() {
1883 let seg_len = seg.len();
1884 let seg_end = seg_start + seg_len;
1885 let ov_start = offset.max(seg_start);
1886 let ov_end = end.min(seg_end);
1887 if ov_start < ov_end {
1888 let within = ov_start - seg_start;
1889 let n = usize_from(ov_end - ov_start);
1890 match seg {
1891 Segment::Inline(bytes) => {
1892 let w = usize_from(within);
1893 out.extend_from_slice(&bytes[w..w + n]);
1894 }
1895 Segment::BackingAudio { offset: bo, .. } => {
1896 let start = out.len();
1897 out.resize(start + n, 0);
1898 use std::os::unix::fs::FileExt;
1899 file.read_exact_at(&mut out[start..], bo + within)?;
1900 }
1901 _ => panic!("unexpected segment in PCM fixture"),
1902 }
1903 }
1904 seg_start = seg_end;
1905 if seg_start >= end {
1906 break;
1907 }
1908 }
1909 Ok(())
1910 }
1911
1912 #[test]
1913 fn pcm_bytes_identical_through_backing_reader() {
1914 let (db, resolved, file) = pcm_fixture();
1915 let pool = ReadAheadPool::new(0);
1916 let buf = Arc::new(Mutex::new(ReadAhead::new(0)));
1917 let epoch = std::sync::atomic::AtomicU64::new(0);
1918 let br = BackingReader::new(&file, &buf, &pool, 0, resolved.stamp.size, &epoch);
1919 let total = resolved.total_len;
1920 for &size in &[1u64, 7, 4096, 65536, 262_144] {
1921 let mut off = 0;
1922 while off < total {
1923 let n = size.min(total - off);
1924 let mut via = Vec::new();
1925 read_segments_into(&resolved, Some(&db), Some(&br), off, n, &mut via).unwrap();
1926 let mut direct = Vec::new();
1927 oracle_read(&resolved, &file, off, n, &mut direct).unwrap();
1928 assert_eq!(via, direct, "mismatch at off={off} size={size}");
1929 off += n;
1930 }
1931 }
1932 }
1933
1934 #[test]
1935 fn pcm_bytes_identical_under_forced_eviction() {
1936 let (db, resolved, file) = pcm_fixture();
1937 let pool = ReadAheadPool::new(1024 * 1024);
1938 let buf = Arc::new(Mutex::new(ReadAhead::new(pool.per_stream_cap())));
1939 pool.register(1, Arc::clone(&buf));
1940 let epoch = std::sync::atomic::AtomicU64::new(0);
1941 let br = BackingReader::new(&file, &buf, &pool, 1, resolved.stamp.size, &epoch);
1942 let total = resolved.total_len;
1943 let mut off = 0;
1944 while off < total {
1945 let n = 65536u64.min(total - off);
1946 let mut via = Vec::new();
1947 read_segments_into(&resolved, Some(&db), Some(&br), off, n, &mut via).unwrap();
1948 let mut direct = Vec::new();
1949 oracle_read(&resolved, &file, off, n, &mut direct).unwrap();
1950 assert_eq!(via, direct, "eviction mismatch at {off}");
1951 off += n;
1952 }
1953 }
1954
1955 #[test]
1956 fn partial_overlap_seek_serves_correct_bytes() {
1957 let (db, resolved, file) = pcm_fixture();
1958 let pool = ReadAheadPool::new(64 * 1024 * 1024);
1959 let buf = Arc::new(Mutex::new(ReadAhead::new(pool.per_stream_cap())));
1960 pool.register(1, Arc::clone(&buf));
1961 let epoch = std::sync::atomic::AtomicU64::new(0);
1962 let br = BackingReader::new(&file, &buf, &pool, 1, resolved.stamp.size, &epoch);
1963 let seq = [(0u64, 600u64), (590, 50), (10, 4096), (12, 4096)];
1964 for &(off, n) in &seq {
1965 let n = n.min(resolved.total_len.saturating_sub(off));
1966 if n == 0 {
1967 continue;
1968 }
1969 let mut via = Vec::new();
1970 read_segments_into(&resolved, Some(&db), Some(&br), off, n, &mut via).unwrap();
1971 let mut direct = Vec::new();
1972 oracle_read(&resolved, &file, off, n, &mut direct).unwrap();
1973 assert_eq!(via, direct, "partial-seek mismatch at {off}+{n}");
1974 }
1975 }
1976}