use std::collections::{HashMap, HashSet};
use std::fmt;
use crate::StreamInfo;
use crate::file::DynamicFileType;
use crate::tagmap::TagMap;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TagDiff {
pub changed: Vec<FieldChange>,
pub left_only: Vec<FieldEntry>,
pub right_only: Vec<FieldEntry>,
pub unchanged: Vec<FieldEntry>,
pub stream_info_diff: Option<StreamInfoDiff>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FieldChange {
pub key: String,
pub left: Vec<String>,
pub right: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FieldEntry {
pub key: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct StreamInfoDiff {
pub length: Option<ValueChange<f64>>,
pub bitrate: Option<ValueChange<u32>>,
pub sample_rate: Option<ValueChange<u32>>,
pub channels: Option<ValueChange<u16>>,
pub bits_per_sample: Option<ValueChange<u16>>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ValueChange<T> {
pub left: Option<T>,
pub right: Option<T>,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DiffOptions {
pub compare_stream_info: bool,
pub case_insensitive_keys: bool,
pub trim_values: bool,
pub include_keys: Option<HashSet<String>>,
pub exclude_keys: HashSet<String>,
pub include_unchanged: bool,
pub normalize_custom_keys: bool,
}
impl StreamInfoDiff {
pub fn is_identical(&self) -> bool {
self.length.is_none()
&& self.bitrate.is_none()
&& self.sample_rate.is_none()
&& self.channels.is_none()
&& self.bits_per_sample.is_none()
}
}
impl TagDiff {
pub fn is_identical(&self) -> bool {
self.changed.is_empty()
&& self.left_only.is_empty()
&& self.right_only.is_empty()
&& self
.stream_info_diff
.as_ref()
.is_none_or(|s| s.is_identical())
}
pub fn diff_count(&self) -> usize {
self.changed.len() + self.left_only.len() + self.right_only.len()
}
pub fn differing_keys(&self) -> Vec<&str> {
let mut keys: Vec<&str> = Vec::new();
for c in &self.changed {
keys.push(&c.key);
}
for e in &self.left_only {
keys.push(&e.key);
}
for e in &self.right_only {
keys.push(&e.key);
}
keys.sort();
keys
}
pub fn get_change(&self, key: &str) -> Option<&FieldChange> {
self.changed.iter().find(|c| c.key == key)
}
pub fn filter_keys(&self, keys: &[&str]) -> TagDiff {
let set: HashSet<&str> = keys.iter().copied().collect();
TagDiff {
changed: self
.changed
.iter()
.filter(|c| set.contains(c.key.as_str()))
.cloned()
.collect(),
left_only: self
.left_only
.iter()
.filter(|e| set.contains(e.key.as_str()))
.cloned()
.collect(),
right_only: self
.right_only
.iter()
.filter(|e| set.contains(e.key.as_str()))
.cloned()
.collect(),
unchanged: self
.unchanged
.iter()
.filter(|e| set.contains(e.key.as_str()))
.cloned()
.collect(),
stream_info_diff: self.stream_info_diff.clone(),
}
}
pub fn exclude_keys(&self, keys: &[&str]) -> TagDiff {
let set: HashSet<&str> = keys.iter().copied().collect();
TagDiff {
changed: self
.changed
.iter()
.filter(|c| !set.contains(c.key.as_str()))
.cloned()
.collect(),
left_only: self
.left_only
.iter()
.filter(|e| !set.contains(e.key.as_str()))
.cloned()
.collect(),
right_only: self
.right_only
.iter()
.filter(|e| !set.contains(e.key.as_str()))
.cloned()
.collect(),
unchanged: self
.unchanged
.iter()
.filter(|e| !set.contains(e.key.as_str()))
.cloned()
.collect(),
stream_info_diff: self.stream_info_diff.clone(),
}
}
pub fn summary(&self) -> String {
format!(
"{} changed, {} removed, {} added, {} unchanged",
self.changed.len(),
self.left_only.len(),
self.right_only.len(),
self.unchanged.len(),
)
}
pub fn pprint(&self) -> String {
self.format_pretty(false)
}
pub fn pprint_full(&self) -> String {
self.format_pretty(true)
}
fn format_pretty(&self, show_unchanged: bool) -> String {
let max_key_len = self
.changed
.iter()
.map(|c| c.key.len())
.chain(self.left_only.iter().map(|e| e.key.len()))
.chain(self.right_only.iter().map(|e| e.key.len()))
.chain(if show_unchanged {
Box::new(self.unchanged.iter().map(|e| e.key.len()))
as Box<dyn Iterator<Item = usize>>
} else {
Box::new(std::iter::empty()) as Box<dyn Iterator<Item = usize>>
})
.max()
.unwrap_or(0);
let mut out = String::new();
for c in &self.changed {
out.push_str(&format!(
"~ {:>width$}: {:?} → {:?}\n",
c.key,
c.left,
c.right,
width = max_key_len,
));
}
for e in &self.left_only {
out.push_str(&format!(
"- {:>width$}: {:?}\n",
e.key,
e.values,
width = max_key_len,
));
}
for e in &self.right_only {
out.push_str(&format!(
"+ {:>width$}: {:?}\n",
e.key,
e.values,
width = max_key_len,
));
}
if show_unchanged {
for e in &self.unchanged {
out.push_str(&format!(
"= {:>width$}: {:?}\n",
e.key,
e.values,
width = max_key_len,
));
}
}
if let Some(ref si) = self.stream_info_diff {
if !si.is_identical() {
out.push_str(&format!("{}", si));
}
}
out
}
}
impl fmt::Display for FieldChange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {:?} → {:?}", self.key, self.left, self.right)
}
}
impl fmt::Display for FieldEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {:?}", self.key, self.values)
}
}
impl fmt::Display for StreamInfoDiff {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(ref v) = self.length {
writeln!(
f,
"~ length: {}s → {}s",
v.left.map_or("?".to_string(), |l| format!("{:.1}", l)),
v.right.map_or("?".to_string(), |r| format!("{:.1}", r)),
)?;
}
if let Some(ref v) = self.bitrate {
writeln!(
f,
"~ bitrate: {} → {}",
v.left.map_or("?".to_string(), |l| l.to_string()),
v.right.map_or("?".to_string(), |r| r.to_string()),
)?;
}
if let Some(ref v) = self.sample_rate {
writeln!(
f,
"~ sample_rate: {} → {}",
v.left.map_or("?".to_string(), |l| l.to_string()),
v.right.map_or("?".to_string(), |r| r.to_string()),
)?;
}
if let Some(ref v) = self.channels {
writeln!(
f,
"~ channels: {} → {}",
v.left.map_or("?".to_string(), |l| l.to_string()),
v.right.map_or("?".to_string(), |r| r.to_string()),
)?;
}
if let Some(ref v) = self.bits_per_sample {
writeln!(
f,
"~ bits_per_sample: {} → {}",
v.left.map_or("?".to_string(), |l| l.to_string()),
v.right.map_or("?".to_string(), |r| r.to_string()),
)?;
}
Ok(())
}
}
impl fmt::Display for TagDiff {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_identical() {
return write!(f, "No differences");
}
writeln!(f, "--- left")?;
writeln!(f, "+++ right")?;
for c in &self.changed {
writeln!(f, "~ {}", c)?;
}
for e in &self.left_only {
writeln!(f, "- {}", e)?;
}
for e in &self.right_only {
writeln!(f, "+ {}", e)?;
}
if let Some(ref si) = self.stream_info_diff {
if !si.is_identical() {
write!(f, "{}", si)?;
}
}
Ok(())
}
}
pub fn diff(left: &DynamicFileType, right: &DynamicFileType) -> TagDiff {
debug_event!("computing tag diff between two files");
let result = diff_items(&left.items(), &right.items());
debug_event!(
changed = result.changed.len(),
removed = result.left_only.len(),
added = result.right_only.len(),
"diff complete"
);
result
}
pub fn diff_with_options(
left: &DynamicFileType,
right: &DynamicFileType,
options: &DiffOptions,
) -> TagDiff {
debug_event!(?options, "computing tag diff with options");
let mut result = diff_items_with_options(&left.items(), &right.items(), options);
if options.compare_stream_info {
result.stream_info_diff = Some(compute_stream_info_diff(&left.info(), &right.info()));
}
debug_event!(
changed = result.changed.len(),
removed = result.left_only.len(),
added = result.right_only.len(),
"diff with options complete"
);
result
}
pub fn diff_items(left: &[(String, Vec<String>)], right: &[(String, Vec<String>)]) -> TagDiff {
diff_items_with_options(left, right, &DiffOptions::default())
}
pub fn diff_items_with_options(
left: &[(String, Vec<String>)],
right: &[(String, Vec<String>)],
options: &DiffOptions,
) -> TagDiff {
let normalise_key = |k: &str| -> String {
if options.case_insensitive_keys {
k.to_lowercase()
} else {
k.to_string()
}
};
let normalise_values = |vals: &[String]| -> Vec<String> {
if options.trim_values {
vals.iter().map(|v| v.trim().to_string()).collect()
} else {
vals.to_vec()
}
};
let should_include = |key: &str| -> bool {
let norm = normalise_key(key);
if options
.exclude_keys
.iter()
.any(|k| normalise_key(k) == norm)
{
return false;
}
if let Some(ref include) = options.include_keys {
return include.iter().any(|k| normalise_key(k) == norm);
}
true
};
let mut left_map: HashMap<String, Vec<Vec<String>>> = HashMap::new();
for (k, v) in left.iter().filter(|(k, _)| should_include(k)) {
left_map
.entry(normalise_key(k))
.or_default()
.push(normalise_values(v));
}
let mut right_map: HashMap<String, Vec<Vec<String>>> = HashMap::new();
for (k, v) in right.iter().filter(|(k, _)| should_include(k)) {
right_map
.entry(normalise_key(k))
.or_default()
.push(normalise_values(v));
}
let flatten = |groups: &Vec<Vec<String>>| -> Vec<String> {
groups.iter().flat_map(|v| v.iter().cloned()).collect()
};
let mut changed = Vec::new();
let mut left_only = Vec::new();
let mut unchanged = Vec::new();
for (key, left_groups) in &left_map {
let left_vals = flatten(left_groups);
if let Some(right_groups) = right_map.get(key) {
let right_vals = flatten(right_groups);
if left_vals == right_vals {
if options.include_unchanged {
trace_event!(key = %key, "unchanged field");
unchanged.push(FieldEntry {
key: key.clone(),
values: left_vals,
});
}
} else {
trace_event!(key = %key, "changed field");
changed.push(FieldChange {
key: key.clone(),
left: left_vals,
right: right_vals,
});
}
} else {
trace_event!(key = %key, "left-only field");
left_only.push(FieldEntry {
key: key.clone(),
values: left_vals,
});
}
}
let mut right_only: Vec<FieldEntry> = right_map
.iter()
.filter(|(k, _)| !left_map.contains_key(*k))
.map(|(k, v)| {
trace_event!(key = %k, "right-only field");
FieldEntry {
key: k.clone(),
values: flatten(v),
}
})
.collect();
changed.sort_by(|a, b| a.key.cmp(&b.key));
left_only.sort_by(|a, b| a.key.cmp(&b.key));
right_only.sort_by(|a, b| a.key.cmp(&b.key));
unchanged.sort_by(|a, b| a.key.cmp(&b.key));
TagDiff {
changed,
left_only,
right_only,
unchanged,
stream_info_diff: None,
}
}
pub fn snapshot_tags(file: &DynamicFileType) -> Vec<(String, Vec<String>)> {
file.items()
}
pub fn diff_against_snapshot(
file: &DynamicFileType,
snapshot: &[(String, Vec<String>)],
) -> TagDiff {
diff_items(snapshot, &file.items())
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(left, right)))]
pub fn diff_normalized(left: &DynamicFileType, right: &DynamicFileType) -> TagDiff {
debug_event!("computing normalized tag diff via TagMap");
let left_items = tag_map_to_items(&left.to_tag_map());
let right_items = tag_map_to_items(&right.to_tag_map());
let result = diff_items(&left_items, &right_items);
debug_event!(
changed = result.changed.len(),
removed = result.left_only.len(),
added = result.right_only.len(),
"normalized diff complete"
);
result
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(left, right)))]
pub fn diff_normalized_with_options(
left: &DynamicFileType,
right: &DynamicFileType,
options: &DiffOptions,
) -> TagDiff {
debug_event!(?options, "computing normalized tag diff with options");
let left_items = if options.normalize_custom_keys {
tag_map_to_items_normalized(&left.to_tag_map())
} else {
tag_map_to_items(&left.to_tag_map())
};
let right_items = if options.normalize_custom_keys {
tag_map_to_items_normalized(&right.to_tag_map())
} else {
tag_map_to_items(&right.to_tag_map())
};
let mut result = diff_items_with_options(&left_items, &right_items, options);
if options.compare_stream_info {
result.stream_info_diff = Some(compute_stream_info_diff(&left.info(), &right.info()));
}
debug_event!(
changed = result.changed.len(),
removed = result.left_only.len(),
added = result.right_only.len(),
"normalized diff with options complete"
);
result
}
fn tag_map_to_items(map: &TagMap) -> Vec<(String, Vec<String>)> {
let mut items: Vec<(String, Vec<String>)> = Vec::new();
for (field, values) in map.standard_fields() {
items.push((field.to_string(), values.to_vec()));
}
for (key, values) in map.custom_fields() {
items.push((key.to_string(), values.to_vec()));
}
items
}
fn tag_map_to_items_normalized(map: &TagMap) -> Vec<(String, Vec<String>)> {
let mut items: Vec<(String, Vec<String>)> = Vec::new();
for (field, values) in map.standard_fields() {
items.push((field.to_string(), values.to_vec()));
}
for (key, values) in map.custom_fields() {
items.push((normalize_custom_key(key), values.to_vec()));
}
items
}
fn normalize_custom_key(key: &str) -> String {
let stripped = if let Some(rest) = key.strip_prefix("id3:") {
rest
} else if let Some(rest) = key.strip_prefix("vorbis:") {
rest
} else if let Some(rest) = key.strip_prefix("mp4:") {
rest
} else if let Some(rest) = key.strip_prefix("ape:") {
rest
} else if let Some(rest) = key.strip_prefix("asf:") {
rest
} else if let Some(rest) = key.strip_prefix("unknown:") {
rest
} else {
key
};
if let Some(desc) = stripped.strip_prefix("TXXX:") {
return desc.to_lowercase();
}
if let Some(rest) = stripped.strip_prefix("----:") {
if let Some(pos) = rest.find(':') {
return rest[pos + 1..].to_lowercase();
}
return rest.to_lowercase();
}
stripped.to_lowercase()
}
fn compute_stream_info_diff(
left: &crate::file::DynamicStreamInfo,
right: &crate::file::DynamicStreamInfo,
) -> StreamInfoDiff {
let length = {
let l = left.length().map(|d| d.as_secs_f64());
let r = right.length().map(|d| d.as_secs_f64());
let equal = match (l, r) {
(Some(a), Some(b)) => (a - b).abs() < 1e-6,
(None, None) => true,
_ => false,
};
if equal {
None
} else {
Some(ValueChange { left: l, right: r })
}
};
let bitrate = diff_optional_field(left.bitrate(), right.bitrate());
let sample_rate = diff_optional_field(left.sample_rate(), right.sample_rate());
let channels = diff_optional_field(left.channels(), right.channels());
let bits_per_sample = diff_optional_field(left.bits_per_sample(), right.bits_per_sample());
StreamInfoDiff {
length,
bitrate,
sample_rate,
channels,
bits_per_sample,
}
}
fn diff_optional_field<T: PartialEq + Copy>(
left: Option<T>,
right: Option<T>,
) -> Option<ValueChange<T>> {
if left != right {
Some(ValueChange { left, right })
} else {
None
}
}