use std::hash::{Hash, Hasher};
use crate::lineage::LineageContext;
use crate::model::Clip;
use crate::tag::TrackMetadata;
fn digest(bytes: &[u8]) -> String {
let mut hasher = fnv::FnvHasher::default();
hasher.write(bytes);
format!("{:016x}", hasher.finish())
}
pub fn content_hash(text: &str) -> String {
digest(text.as_bytes())
}
pub fn meta_hash(clip: &Clip, lineage: &LineageContext) -> String {
let mut hasher = fnv::FnvHasher::default();
TrackMetadata::from_clip(clip, lineage).hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn art_url_hash(url: &str) -> String {
if url.is_empty() {
String::new()
} else {
digest(url.as_bytes())
}
}
pub const SYNCED_LRC_VERSION: u32 = 2;
pub fn synced_lrc_source_hash(clip_id: &str) -> String {
content_hash(&format!("synced-lrc/v{SYNCED_LRC_VERSION}/{clip_id}"))
}
pub fn art_hash(clip: &Clip) -> String {
art_url_hash(clip.selected_image_url().unwrap_or(""))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lineage::{EdgeType, ResolveStatus};
fn sample() -> Clip {
Clip {
title: "Electric Storm".to_owned(),
tags: "ambient, cinematic".to_owned(),
image_large_url: "https://cdn1.suno.ai/image_large_abc.jpeg".to_owned(),
image_url: "https://cdn1.suno.ai/image_abc.jpeg".to_owned(),
video_cover_url: String::new(),
root_ancestor_id: "root-1".to_owned(),
lineage_status: "continuation".to_owned(),
album_title: "Weather Series".to_owned(),
prompt: "an orchestral storm".to_owned(),
lyrics: "thunder rolls\nover the plains".to_owned(),
gpt_description_prompt: "stormy".to_owned(),
handle: "alice".to_owned(),
display_name: "Alice".to_owned(),
..Default::default()
}
}
fn sample_lineage() -> LineageContext {
LineageContext {
root_id: "root-1".to_owned(),
root_title: "Weather Series".to_owned(),
root_date: "2023-05-01T00:00:00Z".to_owned(),
parent_id: "parent-1".to_owned(),
edge_type: Some(EdgeType::Extend),
status: ResolveStatus::Resolved,
}
}
#[test]
fn meta_hash_is_stable() {
let h = meta_hash(&sample(), &sample_lineage());
assert_eq!(h, "c247d31f60378b86");
assert_eq!(h.len(), 16);
assert_eq!(h, meta_hash(&sample(), &sample_lineage()));
}
#[test]
fn art_hash_is_stable_and_empty_without_art() {
let h = art_hash(&sample());
assert_eq!(h.len(), 16);
assert_eq!(h, art_hash(&sample()));
let mut bare = sample();
bare.image_large_url = String::new();
bare.image_url = String::new();
bare.video_cover_url = String::new();
assert_eq!(art_hash(&bare), "");
}
#[test]
fn art_url_hash_is_stable_and_empty_for_empty_url() {
assert_eq!(art_url_hash(""), "");
let h = art_url_hash("https://cdn1.suno.ai/video_cover.mp4");
assert_eq!(h.len(), 16);
assert_eq!(h, art_url_hash("https://cdn1.suno.ai/video_cover.mp4"));
assert_ne!(h, art_url_hash("https://cdn1.suno.ai/other.mp4"));
assert_eq!(
art_hash(&sample()),
art_url_hash(sample().selected_image_url().unwrap())
);
}
#[test]
fn meta_hash_tracks_the_artist_and_model_but_not_sidecar_only_fields() {
let lineage = sample_lineage();
let base = meta_hash(&sample(), &lineage);
let mut artist = sample();
artist.display_name = "Someone Else".to_owned();
assert_ne!(meta_hash(&artist, &lineage), base);
let mut model = sample();
model.model_name = "chirp-v9".to_owned();
assert_ne!(meta_hash(&model, &lineage), base);
let mut cover = sample();
cover.video_cover_url = "https://cdn1.suno.ai/new_cover.mp4".to_owned();
assert_eq!(meta_hash(&cover, &lineage), base);
}
#[test]
fn meta_hash_changes_when_a_content_field_changes() {
let lineage = sample_lineage();
let base = meta_hash(&sample(), &lineage);
for mutate in [
|c: &mut Clip| c.title = "Different".to_owned(),
|c: &mut Clip| c.tags = "lofi".to_owned(),
|c: &mut Clip| c.handle = "bob".to_owned(),
|c: &mut Clip| c.lyrics = "new words".to_owned(),
] {
let mut clip = sample();
mutate(&mut clip);
assert_ne!(meta_hash(&clip, &lineage), base);
}
for mutate in [
|l: &mut LineageContext| l.parent_id = "other-parent".to_owned(),
|l: &mut LineageContext| l.root_id = "other-root".to_owned(),
|l: &mut LineageContext| l.root_title = "Other Album".to_owned(),
|l: &mut LineageContext| l.edge_type = Some(EdgeType::Cover),
|l: &mut LineageContext| l.root_date = "2099-01-01T00:00:00Z".to_owned(),
] {
let mut lin = sample_lineage();
mutate(&mut lin);
assert_ne!(meta_hash(&sample(), &lin), base);
}
}
#[test]
fn art_hash_tracks_the_selected_url_in_preference_order() {
let mut clip = sample();
let large = art_hash(&clip);
clip.image_large_url = String::new();
let standard = art_hash(&clip);
assert_ne!(large, standard);
clip.image_url = String::new();
clip.video_cover_url = "https://cdn1.suno.ai/video_cover.jpeg".to_owned();
let video = art_hash(&clip);
assert_ne!(standard, video);
}
#[test]
fn content_hash_is_stable_and_tracks_any_change() {
let text = "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:60,One\nA/One.flac\n";
let h = content_hash(text);
assert_eq!(h.len(), 16);
assert_eq!(h, content_hash(text), "same text hashes the same");
assert_ne!(
h,
content_hash("#EXTM3U\n#PLAYLIST:Other\n#EXTINF:60,One\nA/One.flac\n")
);
assert_ne!(
h,
content_hash("#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:61,One\nA/One.flac\n")
);
}
#[test]
fn synced_lrc_source_hash_is_stable_per_clip_and_never_empty() {
let a = synced_lrc_source_hash("clip-a");
assert_eq!(a.len(), 16);
assert_eq!(a, synced_lrc_source_hash("clip-a"), "stable per clip id");
assert_ne!(a, synced_lrc_source_hash("clip-b"));
assert!(!a.is_empty());
}
}