bc9fa36646
Week 10 - Plugin System (FR-19): - Plugin traits: Plugin, OriginPlugin, MetadataPlugin, FormatPlugin - NativePluginHost with libloading for dynamic loading - WasmPluginHost (feature-gated) with wasmtime runtime - PluginManager coordinating both hosts with version checks - OriginInstance::watch() with WatchHandle, WatchEvent for live updates - FormatPlugin::synthesize_header() for metadata overlay Week 11 - Control API & Production (FR-17, FR-18, NFR-6, NFR-10): - gRPC server with full MusicFS service (status, cache, origins, events) - Proto extended: MountState enum, TierStats, full StatusResponse/CacheStats - WebhookHandler with HMAC-SHA256 signing and exponential retry - Metrics with latency histograms (p50/p95/p99) and origin health gauges - CLI with mount, status, cache, search, origin, events, shutdown commands - E2E player compatibility tests (mpv, VLC, file manager) - systemd service, PKGBUILD, RPM spec for packaging Plans added for Weeks 10-14 covering P1 features. All 154 tests passing.
634 lines
20 KiB
Markdown
634 lines
20 KiB
Markdown
# Week 14: Extended Formats & Audio Fingerprinting
|
|
|
|
**Phase**: 5 - P1 Feature Completion
|
|
**Goal**: Audio fingerprint search and audiobook format support
|
|
**Requirements**: FR-14.4, FR-24.2
|
|
|
|
---
|
|
|
|
## Deliverables
|
|
|
|
| Task | Crate | Files | Requirements |
|
|
|------|-------|-------|--------------|
|
|
| Fingerprint indexing | musicfs-search | `fingerprint.rs` | FR-14.4 |
|
|
| Fingerprint search | musicfs-search | `fingerprint_search.rs` | FR-14.4 |
|
|
| M4B audiobook support | musicfs-metadata | `formats/m4b.rs` | FR-24.2 |
|
|
| Chapter extraction | musicfs-metadata | `chapters.rs` | FR-24.2 |
|
|
| Virtual chapter files | musicfs-fuse | `ops/chapters.rs` | FR-24.2 |
|
|
|
|
---
|
|
|
|
## Task 1: Audio Fingerprint Generation
|
|
|
|
### 1.1 Add Dependencies
|
|
|
|
```toml
|
|
# In musicfs-search/Cargo.toml
|
|
[dependencies]
|
|
chromaprint = "0.6"
|
|
symphonia = { version = "0.5", features = ["all"] }
|
|
```
|
|
|
|
### 1.2 Fingerprint Generation (`musicfs-search/src/fingerprint.rs`)
|
|
|
|
```rust
|
|
use chromaprint::{Configuration, Fingerprinter};
|
|
use symphonia::core::audio::SampleBuffer;
|
|
use symphonia::core::codecs::DecoderOptions;
|
|
use std::path::Path;
|
|
|
|
/// Audio fingerprint using Chromaprint algorithm
|
|
#[derive(Debug, Clone)]
|
|
pub struct AudioFingerprint {
|
|
pub raw: Vec<u32>,
|
|
pub duration_secs: u32,
|
|
}
|
|
|
|
impl AudioFingerprint {
|
|
/// Generate fingerprint from audio file (FR-14.4)
|
|
pub fn from_file(path: &Path) -> Result<Self, FingerprintError> {
|
|
let file = std::fs::File::open(path)?;
|
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
|
|
|
let probed = symphonia::default::get_probe()
|
|
.format(&Hint::new(), mss, &FormatOptions::default(), &MetadataOptions::default())?;
|
|
|
|
let mut format = probed.format;
|
|
let track = format.tracks()
|
|
.iter()
|
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
|
.ok_or(FingerprintError::NoAudioTrack)?;
|
|
|
|
let sample_rate = track.codec_params.sample_rate
|
|
.ok_or(FingerprintError::NoSampleRate)?;
|
|
|
|
let mut decoder = symphonia::default::get_codecs()
|
|
.make(&track.codec_params, &DecoderOptions::default())?;
|
|
|
|
// Chromaprint configuration
|
|
let config = Configuration::preset_test1();
|
|
let mut fingerprinter = Fingerprinter::new(&config);
|
|
fingerprinter.start(sample_rate, 1)?; // Mono
|
|
|
|
let mut samples: Vec<i16> = Vec::new();
|
|
let mut duration_samples = 0u64;
|
|
|
|
// Decode and collect samples (first 120 seconds max)
|
|
let max_samples = sample_rate as u64 * 120;
|
|
|
|
loop {
|
|
match format.next_packet() {
|
|
Ok(packet) => {
|
|
let decoded = decoder.decode(&packet)?;
|
|
let mut sample_buf = SampleBuffer::<i16>::new(
|
|
decoded.capacity() as u64,
|
|
*decoded.spec(),
|
|
);
|
|
sample_buf.copy_interleaved_ref(decoded);
|
|
|
|
// Convert to mono if stereo
|
|
let mono: Vec<i16> = if decoded.spec().channels.count() > 1 {
|
|
sample_buf.samples()
|
|
.chunks(decoded.spec().channels.count())
|
|
.map(|chunk| (chunk.iter().map(|&s| s as i32).sum::<i32>() / chunk.len() as i32) as i16)
|
|
.collect()
|
|
} else {
|
|
sample_buf.samples().to_vec()
|
|
};
|
|
|
|
samples.extend(&mono);
|
|
duration_samples += mono.len() as u64;
|
|
|
|
if duration_samples >= max_samples {
|
|
break;
|
|
}
|
|
}
|
|
Err(symphonia::core::errors::Error::IoError(e))
|
|
if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
|
|
// Feed samples to fingerprinter
|
|
fingerprinter.feed(&samples)?;
|
|
fingerprinter.finish()?;
|
|
|
|
let raw = fingerprinter.fingerprint().to_vec();
|
|
let duration_secs = (duration_samples / sample_rate as u64) as u32;
|
|
|
|
Ok(Self { raw, duration_secs })
|
|
}
|
|
|
|
/// Compress fingerprint for storage
|
|
pub fn to_bytes(&self) -> Vec<u8> {
|
|
// Use chromaprint's compressed format
|
|
chromaprint::encode_fingerprint(&self.raw, chromaprint::Algorithm::Test1)
|
|
}
|
|
|
|
/// Decompress fingerprint
|
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FingerprintError> {
|
|
let (raw, _) = chromaprint::decode_fingerprint(bytes)?;
|
|
Ok(Self { raw, duration_secs: 0 })
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum FingerprintError {
|
|
#[error("No audio track found")]
|
|
NoAudioTrack,
|
|
#[error("No sample rate")]
|
|
NoSampleRate,
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("Decode error: {0}")]
|
|
Decode(String),
|
|
#[error("Chromaprint error: {0}")]
|
|
Chromaprint(String),
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Fingerprint Search (`musicfs-search/src/fingerprint_search.rs`)
|
|
|
|
```rust
|
|
use crate::fingerprint::AudioFingerprint;
|
|
|
|
/// Fingerprint similarity search using bit-level comparison
|
|
pub struct FingerprintIndex {
|
|
db: Arc<Database>,
|
|
}
|
|
|
|
impl FingerprintIndex {
|
|
pub fn new(db: Arc<Database>) -> Self {
|
|
Self { db }
|
|
}
|
|
|
|
/// Index a file's fingerprint
|
|
pub fn index(&self, file_id: FileId, fingerprint: &AudioFingerprint) -> Result<(), SearchError> {
|
|
let bytes = fingerprint.to_bytes();
|
|
self.db.store_fingerprint(file_id, &bytes, fingerprint.duration_secs)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Search by fingerprint similarity (FR-14.4)
|
|
pub fn search(
|
|
&self,
|
|
query: &AudioFingerprint,
|
|
threshold: f32, // 0.0-1.0, higher = more similar
|
|
limit: usize,
|
|
) -> Result<Vec<FingerprintMatch>, SearchError> {
|
|
let candidates = self.db.get_fingerprints_by_duration(
|
|
query.duration_secs.saturating_sub(10),
|
|
query.duration_secs + 10,
|
|
)?;
|
|
|
|
let mut matches: Vec<FingerprintMatch> = candidates
|
|
.into_iter()
|
|
.filter_map(|(file_id, fp_bytes, duration)| {
|
|
let fp = AudioFingerprint::from_bytes(&fp_bytes).ok()?;
|
|
let similarity = self.compare(&query.raw, &fp.raw);
|
|
|
|
if similarity >= threshold {
|
|
Some(FingerprintMatch { file_id, similarity, duration })
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Sort by similarity descending
|
|
matches.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap());
|
|
matches.truncate(limit);
|
|
|
|
Ok(matches)
|
|
}
|
|
|
|
/// Compare two fingerprints using bit error rate
|
|
fn compare(&self, a: &[u32], b: &[u32]) -> f32 {
|
|
let len = a.len().min(b.len());
|
|
if len == 0 {
|
|
return 0.0;
|
|
}
|
|
|
|
let mut matching_bits = 0u32;
|
|
let mut total_bits = 0u32;
|
|
|
|
for i in 0..len {
|
|
let xor = a[i] ^ b[i];
|
|
matching_bits += 32 - xor.count_ones();
|
|
total_bits += 32;
|
|
}
|
|
|
|
matching_bits as f32 / total_bits as f32
|
|
}
|
|
|
|
/// Find duplicates by fingerprint
|
|
pub fn find_duplicates(&self, threshold: f32) -> Result<Vec<DuplicateGroup>, SearchError> {
|
|
let all_fps = self.db.get_all_fingerprints()?;
|
|
let mut groups: Vec<DuplicateGroup> = Vec::new();
|
|
let mut processed: HashSet<FileId> = HashSet::new();
|
|
|
|
for (file_id, fp_bytes, duration) in &all_fps {
|
|
if processed.contains(file_id) {
|
|
continue;
|
|
}
|
|
|
|
let fp = AudioFingerprint::from_bytes(fp_bytes)?;
|
|
let matches = self.search(&fp, threshold, 100)?;
|
|
|
|
if matches.len() > 1 {
|
|
let group = DuplicateGroup {
|
|
files: matches.iter().map(|m| m.file_id).collect(),
|
|
similarity: matches.iter().map(|m| m.similarity).sum::<f32>() / matches.len() as f32,
|
|
};
|
|
|
|
for m in &matches {
|
|
processed.insert(m.file_id);
|
|
}
|
|
|
|
groups.push(group);
|
|
}
|
|
}
|
|
|
|
Ok(groups)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct FingerprintMatch {
|
|
pub file_id: FileId,
|
|
pub similarity: f32,
|
|
pub duration: u32,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct DuplicateGroup {
|
|
pub files: Vec<FileId>,
|
|
pub similarity: f32,
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: M4B Audiobook Support (`musicfs-metadata/src/formats/m4b.rs`)
|
|
|
|
```rust
|
|
use symphonia::core::meta::StandardTagKey;
|
|
|
|
/// M4B audiobook metadata (FR-24.2)
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct AudiobookMeta {
|
|
pub title: Option<String>,
|
|
pub author: Option<String>, // Maps to "artist" in audio
|
|
pub narrator: Option<String>,
|
|
pub series: Option<String>,
|
|
pub series_part: Option<u32>,
|
|
pub description: Option<String>,
|
|
pub publisher: Option<String>,
|
|
pub year: Option<u32>,
|
|
pub duration_ms: Option<u64>,
|
|
pub chapters: Vec<Chapter>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Chapter {
|
|
pub index: u32,
|
|
pub title: String,
|
|
pub start_ms: u64,
|
|
pub end_ms: u64,
|
|
}
|
|
|
|
impl Chapter {
|
|
pub fn duration_ms(&self) -> u64 {
|
|
self.end_ms - self.start_ms
|
|
}
|
|
}
|
|
|
|
pub struct M4bParser;
|
|
|
|
impl M4bParser {
|
|
/// Parse M4B audiobook with chapters
|
|
pub fn parse(&self, path: &Path) -> Result<AudiobookMeta, MetadataError> {
|
|
let file = std::fs::File::open(path)?;
|
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
|
|
|
let mut hint = Hint::new();
|
|
hint.with_extension("m4b");
|
|
|
|
let probed = symphonia::default::get_probe()
|
|
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())?;
|
|
|
|
let mut meta = AudiobookMeta::default();
|
|
let format = probed.format;
|
|
|
|
// Extract metadata
|
|
if let Some(metadata) = format.metadata().current() {
|
|
for tag in metadata.tags() {
|
|
if let Some(std_key) = tag.std_key {
|
|
let value = tag.value.to_string();
|
|
match std_key {
|
|
StandardTagKey::TrackTitle | StandardTagKey::Album => {
|
|
meta.title = Some(value);
|
|
}
|
|
StandardTagKey::Artist => {
|
|
meta.author = Some(value);
|
|
}
|
|
StandardTagKey::Composer => {
|
|
meta.narrator = Some(value);
|
|
}
|
|
StandardTagKey::Description => {
|
|
meta.description = Some(value);
|
|
}
|
|
StandardTagKey::Label => {
|
|
meta.publisher = Some(value);
|
|
}
|
|
StandardTagKey::Date => {
|
|
meta.year = value.chars().take(4).collect::<String>().parse().ok();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract chapters from MP4 chpl atom
|
|
meta.chapters = self.extract_chapters(&format)?;
|
|
|
|
// Get total duration
|
|
if let Some(track) = format.tracks().first() {
|
|
if let (Some(n_frames), Some(sample_rate)) =
|
|
(track.codec_params.n_frames, track.codec_params.sample_rate)
|
|
{
|
|
meta.duration_ms = Some((n_frames as u64 * 1000) / sample_rate as u64);
|
|
}
|
|
}
|
|
|
|
Ok(meta)
|
|
}
|
|
|
|
fn extract_chapters(&self, format: &dyn FormatReader) -> Result<Vec<Chapter>, MetadataError> {
|
|
let mut chapters = Vec::new();
|
|
|
|
// Symphonia exposes chapters via cues
|
|
if let Some(cues) = format.cues() {
|
|
for (idx, cue) in cues.iter().enumerate() {
|
|
let start_ms = (cue.start_ts as f64 / cue.start_offset_ts.unwrap_or(1) as f64 * 1000.0) as u64;
|
|
|
|
// End time is start of next chapter or track end
|
|
let end_ms = cues.get(idx + 1)
|
|
.map(|next| (next.start_ts as f64 / next.start_offset_ts.unwrap_or(1) as f64 * 1000.0) as u64)
|
|
.unwrap_or(u64::MAX); // Will be clamped to duration
|
|
|
|
chapters.push(Chapter {
|
|
index: idx as u32,
|
|
title: cue.tags.iter()
|
|
.find(|t| t.std_key == Some(StandardTagKey::TrackTitle))
|
|
.map(|t| t.value.to_string())
|
|
.unwrap_or_else(|| format!("Chapter {}", idx + 1)),
|
|
start_ms,
|
|
end_ms,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(chapters)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Chapter Extraction (`musicfs-metadata/src/chapters.rs`)
|
|
|
|
```rust
|
|
/// Generic chapter support for various formats
|
|
pub trait ChapterSource {
|
|
fn chapters(&self) -> &[Chapter];
|
|
fn chapter_at(&self, position_ms: u64) -> Option<&Chapter>;
|
|
}
|
|
|
|
impl ChapterSource for AudiobookMeta {
|
|
fn chapters(&self) -> &[Chapter] {
|
|
&self.chapters
|
|
}
|
|
|
|
fn chapter_at(&self, position_ms: u64) -> Option<&Chapter> {
|
|
self.chapters.iter()
|
|
.find(|c| position_ms >= c.start_ms && position_ms < c.end_ms)
|
|
}
|
|
}
|
|
|
|
/// Virtual chapter file generator
|
|
pub struct ChapterFileGenerator;
|
|
|
|
impl ChapterFileGenerator {
|
|
/// Generate virtual files for each chapter
|
|
/// Example: book.m4b -> book/01 - Introduction.m4b.chapter
|
|
pub fn generate_virtual_files(&self, meta: &AudiobookMeta, base_path: &VirtualPath) -> Vec<VirtualChapterFile> {
|
|
meta.chapters.iter()
|
|
.map(|chapter| {
|
|
let filename = format!(
|
|
"{:02} - {}.chapter",
|
|
chapter.index + 1,
|
|
sanitize_filename(&chapter.title)
|
|
);
|
|
|
|
VirtualChapterFile {
|
|
path: base_path.join(&filename),
|
|
chapter_index: chapter.index,
|
|
start_ms: chapter.start_ms,
|
|
end_ms: chapter.end_ms,
|
|
title: chapter.title.clone(),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct VirtualChapterFile {
|
|
pub path: VirtualPath,
|
|
pub chapter_index: u32,
|
|
pub start_ms: u64,
|
|
pub end_ms: u64,
|
|
pub title: String,
|
|
}
|
|
|
|
fn sanitize_filename(name: &str) -> String {
|
|
name.chars()
|
|
.map(|c| match c {
|
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
|
_ => c,
|
|
})
|
|
.collect()
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Virtual Chapter Files (`musicfs-fuse/src/ops/chapters.rs`)
|
|
|
|
```rust
|
|
use crate::VirtualFs;
|
|
|
|
impl VirtualFs {
|
|
/// Handle reads from virtual chapter files
|
|
/// These return a byte-range reference to the parent M4B file
|
|
pub async fn read_chapter(
|
|
&self,
|
|
chapter_file: &VirtualChapterFile,
|
|
offset: u64,
|
|
size: usize,
|
|
) -> Result<Vec<u8>, FuseError> {
|
|
// Get the parent audiobook file
|
|
let parent = self.get_parent_audiobook(&chapter_file.path)?;
|
|
|
|
// Calculate byte range for this chapter
|
|
// This requires knowing the audio bitrate to convert ms -> bytes
|
|
let meta = self.get_audiobook_meta(&parent)?;
|
|
let bitrate_bps = meta.bitrate.unwrap_or(128_000); // Default 128kbps
|
|
let bytes_per_ms = bitrate_bps / 8 / 1000;
|
|
|
|
let chapter_start_bytes = chapter_file.start_ms * bytes_per_ms;
|
|
let chapter_end_bytes = chapter_file.end_ms * bytes_per_ms;
|
|
|
|
// Adjust offset to be within chapter
|
|
let actual_offset = chapter_start_bytes + offset;
|
|
let max_size = (chapter_end_bytes - actual_offset) as usize;
|
|
let read_size = size.min(max_size);
|
|
|
|
// Read from the actual file
|
|
self.read_file(&parent, actual_offset, read_size).await
|
|
}
|
|
|
|
/// List chapter files for an audiobook
|
|
pub fn list_chapters(&self, audiobook_path: &VirtualPath) -> Result<Vec<DirEntry>, FuseError> {
|
|
let meta = self.get_audiobook_meta(audiobook_path)?;
|
|
let generator = ChapterFileGenerator;
|
|
|
|
let chapters = generator.generate_virtual_files(&meta, audiobook_path);
|
|
|
|
Ok(chapters.into_iter()
|
|
.map(|c| DirEntry {
|
|
name: c.path.filename().to_string(),
|
|
kind: FileType::RegularFile,
|
|
size: self.estimate_chapter_size(&c),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn estimate_chapter_size(&self, chapter: &VirtualChapterFile) -> u64 {
|
|
// Estimate based on duration and typical bitrate
|
|
let duration_secs = (chapter.end_ms - chapter.start_ms) / 1000;
|
|
duration_secs * 128_000 / 8 // 128kbps assumption
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Fingerprint Search Virtual Directory
|
|
|
|
```rust
|
|
/// Virtual directory for fingerprint search
|
|
/// /.search/fingerprint/{base64_fingerprint} -> matching files
|
|
|
|
impl SearchOps {
|
|
pub async fn search_by_fingerprint(
|
|
&self,
|
|
fingerprint_path: &str,
|
|
) -> Result<Vec<SearchResult>, SearchError> {
|
|
// Path format: /.search/fingerprint/{base64_encoded_fingerprint}
|
|
let fp_bytes = base64::decode(fingerprint_path)
|
|
.map_err(|_| SearchError::InvalidQuery)?;
|
|
|
|
let fingerprint = AudioFingerprint::from_bytes(&fp_bytes)?;
|
|
let matches = self.fingerprint_index.search(&fingerprint, 0.8, 20)?;
|
|
|
|
let mut results = Vec::new();
|
|
for m in matches {
|
|
if let Some(file) = self.db.get_file_by_id(m.file_id)? {
|
|
results.push(SearchResult {
|
|
path: file.virtual_path,
|
|
score: m.similarity,
|
|
snippet: format!("Similarity: {:.1}%", m.similarity * 100.0),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Database Schema Additions
|
|
|
|
```sql
|
|
-- Fingerprint storage
|
|
CREATE TABLE IF NOT EXISTS fingerprints (
|
|
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
|
|
fingerprint BLOB NOT NULL, -- Compressed chromaprint
|
|
duration INTEGER NOT NULL, -- Duration in seconds
|
|
indexed_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_fingerprints_duration ON fingerprints(duration);
|
|
|
|
-- Audiobook chapters
|
|
CREATE TABLE IF NOT EXISTS chapters (
|
|
id INTEGER PRIMARY KEY,
|
|
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
chapter_idx INTEGER NOT NULL,
|
|
title TEXT NOT NULL,
|
|
start_ms INTEGER NOT NULL,
|
|
end_ms INTEGER NOT NULL,
|
|
UNIQUE(file_id, chapter_idx)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_chapters_file ON chapters(file_id);
|
|
```
|
|
|
|
---
|
|
|
|
## Tests
|
|
|
|
| Test | Type | Validates |
|
|
|------|------|-----------|
|
|
| `test_fingerprint_generation` | Unit | Chromaprint from audio (FR-14.4) |
|
|
| `test_fingerprint_similarity` | Unit | Bit comparison algorithm |
|
|
| `test_fingerprint_search` | Integration | Find similar tracks |
|
|
| `test_fingerprint_duplicates` | Integration | Detect duplicate audio |
|
|
| `test_m4b_parsing` | Unit | M4B metadata extraction (FR-24.2) |
|
|
| `test_chapter_extraction` | Unit | Chapter list from M4B |
|
|
| `test_virtual_chapter_files` | Integration | Chapter files appear in listing |
|
|
| `test_chapter_read` | Integration | Read chapter content |
|
|
| `test_audiobook_navigation` | E2E | Browse audiobook chapters |
|
|
|
|
---
|
|
|
|
## Exit Criteria
|
|
|
|
- [ ] Audio fingerprints generated from audio files
|
|
- [ ] Fingerprint similarity search finds matching tracks
|
|
- [ ] Duplicate detection works across library
|
|
- [ ] M4B files parsed with full metadata
|
|
- [ ] Chapters extracted and stored
|
|
- [ ] Virtual chapter files appear in directory listing
|
|
- [ ] Chapter files are readable (return correct byte range)
|
|
- [ ] All tests pass
|
|
|
|
---
|
|
|
|
## Architecture Alignment
|
|
|
|
Per requirements.md:
|
|
- FR-14.4: Audio fingerprint search ✓
|
|
- FR-24.2: Audiobook formats with chapters ✓
|
|
|
|
Per architecture.md section 4.3.4:
|
|
- FormatPlugin trait for M4B support ✓
|
|
- Chapter extraction via symphonia ✓
|