Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 58 additions & 13 deletions src/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
//! and [chromaprint-next](https://docs.rs/chromaprint-next) for fingerprint generation.
//! The fingerprints are bit-identical to those produced by the reference `fpcalc` tool.

use anyhow::{Context, Result};
use chromaprint::{Algorithm, Fingerprinter};
use std::path::Path;
use symphonia::core::audio::{Audio, GenericAudioBufferRef};
Expand All @@ -18,6 +17,50 @@ use symphonia::core::meta::MetadataOptions;
/// Maximum duration to fingerprint (in seconds). Chromaprint only needs ~120s.
pub const DEFAULT_FINGERPRINT_DURATION_SECS: u64 = 120;

/// Categorized reasons why fingerprinting can fail.
#[derive(Debug)]
pub enum FingerprintError {
/// File could not be opened (permissions, missing, locked).
OpenFailed(std::io::Error),
/// Audio format not recognized or file too short/corrupted to probe.
UnrecognizedFormat(String),
/// No audio track found in the container (e.g. video-only file).
NoAudioTrack,
/// Codec is unsupported or audio data is corrupted.
DecodeFailed(String),
/// Fingerprint was empty (file may be silence or too short).
EmptyFingerprint,
}

impl std::fmt::Display for FingerprintError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OpenFailed(e) => write!(f, "could not open file ({e})"),
Self::UnrecognizedFormat(detail) => write!(
f,
"not a recognized audio format or file is too short/corrupted ({detail})"
),
Self::NoAudioTrack => write!(f, "no audio track found in file"),
Self::DecodeFailed(detail) => {
write!(f, "unsupported or corrupted audio codec ({detail})")
}
Self::EmptyFingerprint => write!(
f,
"could not extract audio content (file may be silence or too short)"
),
}
}
}

impl std::error::Error for FingerprintError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::OpenFailed(e) => Some(e),
_ => None,
}
}
}

/// Result of fingerprinting a single audio file.
#[derive(Debug, Clone)]
pub struct FingerprintResult {
Expand All @@ -35,9 +78,11 @@ pub struct FingerprintResult {
/// The fingerprint is generated from the first `max_duration_secs` of audio,
/// but `duration_secs` reports the full file duration (critical for deduplication
/// since chromaprint can't distinguish files that only differ after the fingerprinted portion).
pub fn fingerprint_file(path: &Path, max_duration_secs: u64) -> Result<FingerprintResult> {
let file =
std::fs::File::open(path).with_context(|| format!("Failed to open: {}", path.display()))?;
pub fn fingerprint_file(
path: &Path,
max_duration_secs: u64,
) -> Result<FingerprintResult, FingerprintError> {
let file = std::fs::File::open(path).map_err(FingerprintError::OpenFailed)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());

let mut hint = Hint::new();
Expand All @@ -52,19 +97,19 @@ pub fn fingerprint_file(path: &Path, max_duration_secs: u64) -> Result<Fingerpri
FormatOptions::default(),
MetadataOptions::default(),
)
.with_context(|| format!("Failed to probe format: {}", path.display()))?;
.map_err(|e| FingerprintError::UnrecognizedFormat(e.to_string()))?;

let track = format
.default_track(TrackType::Audio)
.ok_or_else(|| anyhow::anyhow!("No audio track found in: {}", path.display()))?
.ok_or(FingerprintError::NoAudioTrack)?
.clone();

let audio_params = get_audio_codec_params(&track)
.ok_or_else(|| anyhow::anyhow!("No audio codec params in: {}", path.display()))?;
.ok_or_else(|| FingerprintError::DecodeFailed("no audio codec params".to_string()))?;

let sample_rate = audio_params
.sample_rate
.ok_or_else(|| anyhow::anyhow!("Unknown sample rate: {}", path.display()))?;
.ok_or_else(|| FingerprintError::DecodeFailed("unknown sample rate".to_string()))?;
let channels = audio_params
.channels
.as_ref()
Expand All @@ -77,12 +122,12 @@ pub fn fingerprint_file(path: &Path, max_duration_secs: u64) -> Result<Fingerpri

let mut decoder = symphonia::default::get_codecs()
.make_audio_decoder(&audio_params, &AudioDecoderOptions::default())
.with_context(|| format!("Failed to create decoder: {}", path.display()))?;
.map_err(|e| FingerprintError::DecodeFailed(e.to_string()))?;

let mut printer = Fingerprinter::new(Algorithm::default());
printer
.start(sample_rate, channels as u16)
.map_err(|e| anyhow::anyhow!("Failed to start fingerprinter: {e}"))?;
.map_err(|e| FingerprintError::DecodeFailed(format!("fingerprinter start: {e}")))?;

let max_samples = max_duration_secs * sample_rate as u64 * channels as u64;
let mut total_samples: u64 = 0;
Expand All @@ -97,7 +142,7 @@ pub fn fingerprint_file(path: &Path, max_duration_secs: u64) -> Result<Fingerpri
{
break;
}
Err(e) => return Err(e.into()),
Err(e) => return Err(FingerprintError::DecodeFailed(e.to_string())),
};

if packet.track_id != track_id {
Expand All @@ -107,7 +152,7 @@ pub fn fingerprint_file(path: &Path, max_duration_secs: u64) -> Result<Fingerpri
let decoded = match decoder.decode(&packet) {
Ok(buf) => buf,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(e) => return Err(e.into()),
Err(e) => return Err(FingerprintError::DecodeFailed(e.to_string())),
};

if !finished_fingerprinting {
Expand Down Expand Up @@ -149,7 +194,7 @@ pub fn fingerprint_file(path: &Path, max_duration_secs: u64) -> Result<Fingerpri

let fingerprint = printer.fingerprint().to_vec();
if fingerprint.is_empty() {
anyhow::bail!("Empty fingerprint for: {}", path.display());
return Err(FingerprintError::EmptyFingerprint);
}

let duration_secs = full_duration_secs
Expand Down
6 changes: 5 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,13 @@ fn main() -> Result<()> {
2 => "debug",
_ => "trace",
};
// Demote symphonia's internal tracing to debug level. Its messages
// (e.g. "probe reached EOF at 4096 bytes") lack file context and confuse users.
// We emit our own warnings with filename and user-friendly explanations instead.
let filter_str = format!("{filter},symphonia_core=debug,symphonia_format_ogg=debug");
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)),
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&filter_str)),
)
.init();

Expand Down
2 changes: 1 addition & 1 deletion src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ pub fn scan(
}
}
Err(e) => {
debug!("Failed to fingerprint {}: {}", path.display(), e);
warn!("Skipping {}: {}", path.display(), e);
ScanResult::Error {
path: path.clone(),
meta: FileMeta::from_path(path),
Expand Down
131 changes: 131 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,134 @@ fn test_duplicate_classification_5_files() {
);
}
}

#[test]
fn test_fingerprint_nonexistent_file_returns_open_failed() {
use dupsonic::fingerprint::FingerprintError;

let path = PathBuf::from("/nonexistent/path/to/audio.flac");
let result = fingerprint_file(&path, 120);

assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, FingerprintError::OpenFailed(_)),
"Expected OpenFailed, got: {err:?}"
);
// Verify Display message is user-friendly
let msg = err.to_string();
assert!(
msg.contains("could not open file"),
"Error message should be user-friendly, got: {msg}"
);
}

#[test]
fn test_fingerprint_non_audio_file_returns_unrecognized_format() {
use dupsonic::fingerprint::FingerprintError;

// Create a temporary file with garbage content but an audio extension
let dir = TempDir::new().unwrap();
let fake_audio = dir.path().join("not_audio.flac");
std::fs::write(&fake_audio, b"This is not audio data at all").unwrap();

let result = fingerprint_file(&fake_audio, 120);

assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, FingerprintError::UnrecognizedFormat(_)),
"Expected UnrecognizedFormat, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("not a recognized audio format"),
"Error message should be user-friendly, got: {msg}"
);
}

#[test]
fn test_fingerprint_truncated_file_returns_error() {
use dupsonic::fingerprint::FingerprintError;

// Create a file with a valid FLAC magic number but truncated content
let dir = TempDir::new().unwrap();
let truncated = dir.path().join("truncated.flac");
// fLaC magic followed by garbage — enough to start probing but not enough to decode
let mut data = b"fLaC".to_vec();
data.extend_from_slice(&[0u8; 100]);
std::fs::write(&truncated, &data).unwrap();

let result = fingerprint_file(&truncated, 120);

assert!(result.is_err());
let err = result.unwrap_err();
// Could be UnrecognizedFormat or DecodeFailed depending on how far symphonia gets
assert!(
matches!(
err,
FingerprintError::UnrecognizedFormat(_)
| FingerprintError::DecodeFailed(_)
| FingerprintError::EmptyFingerprint
| FingerprintError::NoAudioTrack
),
"Expected a structured error for truncated file, got: {err:?}"
);
// Regardless of variant, Display should produce a useful message
let msg = err.to_string();
assert!(!msg.is_empty(), "Error message should not be empty");
}

#[test]
fn test_fingerprint_empty_file_returns_unrecognized_format() {
use dupsonic::fingerprint::FingerprintError;

let dir = TempDir::new().unwrap();
let empty = dir.path().join("empty.mp3");
std::fs::write(&empty, b"").unwrap();

let result = fingerprint_file(&empty, 120);

assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, FingerprintError::UnrecognizedFormat(_)),
"Expected UnrecognizedFormat for empty file, got: {err:?}"
);
}

#[test]
fn test_fingerprint_error_display_includes_details() {
use dupsonic::fingerprint::FingerprintError;

// Verify that each error variant produces a non-empty, descriptive message
let errors = [
FingerprintError::OpenFailed(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"permission denied",
)),
FingerprintError::UnrecognizedFormat("end of stream".to_string()),
FingerprintError::NoAudioTrack,
FingerprintError::DecodeFailed("unsupported codec".to_string()),
FingerprintError::EmptyFingerprint,
];

let expected_fragments = [
"could not open file",
"not a recognized audio format",
"no audio track found",
"unsupported or corrupted audio codec",
"could not extract audio content",
];

for (err, expected) in errors.iter().zip(expected_fragments.iter()) {
let msg = err.to_string();
assert!(
msg.contains(expected),
"FingerprintError::{:?} display should contain '{}', got: '{}'",
err,
expected,
msg
);
}
}
Loading