Implement Week 2 metadata extraction and cache database

Week 1 fixes:
- Move hex to workspace dependencies
- Add cargo-criterion, protobuf, grpcurl to flake.nix

Week 2 implementation:
- musicfs-metadata: MetadataParser with symphonia 0.5 for FLAC, MP3,
  Opus/Vorbis, M4A/AAC (2 tests)
- musicfs-cache: SQLite schema per architecture 4.3.6 with track/disc
  columns, TEXT content_hash, all required indexes
- musicfs-cache/db.rs: Database with upsert, CRUD, mtime lookup (9 tests)
- musicfs-cache/metadata.rs: MetadataCache with store/lookup/is_fresh/
  invalidate (2 tests)
- musicfs-core: Added Metadata error variant

22 tests pass total. Oracle-verified against architecture doc.
This commit is contained in:
Alexander
2026-05-12 18:15:44 +02:00
parent 76856b893a
commit d664439746
13 changed files with 1289 additions and 12 deletions
+3 -1
View File
@@ -1 +1,3 @@
#![allow(dead_code)]
mod parser;
pub use parser::MetadataParser;
@@ -0,0 +1,134 @@
use musicfs_core::{AudioFormat, AudioMeta, Error, Result};
use std::fs::File;
use std::path::Path;
use symphonia::core::codecs::CODEC_TYPE_NULL;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use tracing::debug;
pub struct MetadataParser;
impl MetadataParser {
pub fn new() -> Self {
Self
}
pub fn parse_file(&self, path: &Path) -> Result<AudioMeta> {
let file = File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let mut hint = Hint::new();
if !ext.is_empty() {
hint.with_extension(ext);
}
let fmt_opts = FormatOptions::default();
let meta_opts = MetadataOptions::default();
let probed = symphonia::default::get_probe()
.format(&hint, mss, &fmt_opts, &meta_opts)
.map_err(|e| Error::Metadata(format!("Failed to probe format: {}", e)))?;
let mut format = probed.format;
let mut audio_meta = AudioMeta {
format: AudioFormat::from_extension(ext),
..Default::default()
};
if let Some(metadata) = format.metadata().current() {
self.extract_tags(&mut audio_meta, metadata);
}
if let Some(track) = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
{
let params = &track.codec_params;
if let Some(n_frames) = params.n_frames {
if let Some(sample_rate) = params.sample_rate {
audio_meta.duration_ms =
Some((n_frames as u64 * 1000) / sample_rate as u64);
audio_meta.sample_rate = Some(sample_rate);
}
}
if let Some(bits_per_sample) = params.bits_per_sample {
if let Some(sample_rate) = params.sample_rate {
if let Some(channels) = params.channels {
audio_meta.bitrate = Some(
bits_per_sample * sample_rate * channels.count() as u32 / 1000,
);
}
}
}
}
debug!(?audio_meta, "Parsed metadata");
Ok(audio_meta)
}
fn extract_tags(
&self,
meta: &mut AudioMeta,
metadata: &symphonia::core::meta::MetadataRevision,
) {
use symphonia::core::meta::StandardTagKey;
for tag in metadata.tags() {
if let Some(std_key) = tag.std_key {
let value = tag.value.to_string();
match std_key {
StandardTagKey::TrackTitle => meta.title = Some(value),
StandardTagKey::Artist => meta.artist = Some(value),
StandardTagKey::Album => meta.album = Some(value),
StandardTagKey::AlbumArtist => meta.album_artist = Some(value),
StandardTagKey::Genre => meta.genre = Some(value),
StandardTagKey::TrackNumber => {
meta.track = value.split('/').next().and_then(|s| s.parse().ok());
}
StandardTagKey::DiscNumber => {
meta.disc = value.split('/').next().and_then(|s| s.parse().ok());
}
StandardTagKey::Date | StandardTagKey::ReleaseDate => {
meta.year = value.chars().take(4).collect::<String>().parse().ok();
}
_ => {}
}
}
}
}
}
impl Default for MetadataParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audio_format_detection() {
assert_eq!(AudioFormat::from_extension("flac"), AudioFormat::Flac);
assert_eq!(AudioFormat::from_extension("mp3"), AudioFormat::Mp3);
assert_eq!(AudioFormat::from_extension("opus"), AudioFormat::Opus);
assert_eq!(AudioFormat::from_extension("ogg"), AudioFormat::Vorbis);
assert_eq!(AudioFormat::from_extension("m4a"), AudioFormat::Aac);
assert_eq!(AudioFormat::from_extension("wav"), AudioFormat::Wav);
}
#[test]
fn test_parser_creation() {
let parser = MetadataParser::new();
let default_parser = MetadataParser::default();
assert!(std::mem::size_of_val(&parser) == std::mem::size_of_val(&default_parser));
}
}