1use std::hash::Hasher;
14
15use crate::lineage::{EdgeType, LineageContext};
16use crate::model::Clip;
17
18fn digest(bytes: &[u8]) -> String {
20 let mut hasher = fnv::FnvHasher::default();
21 hasher.write(bytes);
22 format!("{:016x}", hasher.finish())
23}
24
25pub fn content_hash(text: &str) -> String {
34 digest(text.as_bytes())
35}
36
37pub fn meta_hash(clip: &Clip, lineage: &LineageContext) -> String {
54 let edge_label = lineage.edge_type.map(EdgeType::label).unwrap_or("");
55 let fields = format!(
56 "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
57 clip.title,
58 clip.tags,
59 clip.selected_image_url().unwrap_or(""),
60 clip.video_cover_url,
61 lineage.parent_id,
62 edge_label,
63 lineage.root_id,
64 lineage.root_title,
65 lineage.album(&clip.title),
66 clip.prompt,
67 clip.lyrics,
68 clip.gpt_description_prompt,
69 clip.handle,
70 );
71 digest(fields.as_bytes())
72}
73
74pub fn art_url_hash(url: &str) -> String {
83 if url.is_empty() {
84 String::new()
85 } else {
86 digest(url.as_bytes())
87 }
88}
89
90pub fn art_hash(clip: &Clip) -> String {
94 art_url_hash(clip.selected_image_url().unwrap_or(""))
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use crate::lineage::ResolveStatus;
101
102 fn sample() -> Clip {
103 Clip {
104 title: "Electric Storm".to_owned(),
105 tags: "ambient, cinematic".to_owned(),
106 image_large_url: "https://cdn1.suno.ai/image_large_abc.jpeg".to_owned(),
107 image_url: "https://cdn1.suno.ai/image_abc.jpeg".to_owned(),
108 video_cover_url: String::new(),
109 root_ancestor_id: "root-1".to_owned(),
110 lineage_status: "continuation".to_owned(),
111 album_title: "Weather Series".to_owned(),
112 prompt: "an orchestral storm".to_owned(),
113 lyrics: "thunder rolls\nover the plains".to_owned(),
114 gpt_description_prompt: "stormy".to_owned(),
115 handle: "alice".to_owned(),
116 display_name: "Alice".to_owned(),
117 ..Default::default()
118 }
119 }
120
121 fn sample_lineage() -> LineageContext {
124 LineageContext {
125 root_id: "root-1".to_owned(),
126 root_title: "Weather Series".to_owned(),
127 parent_id: "parent-1".to_owned(),
128 edge_type: Some(EdgeType::Extend),
129 status: ResolveStatus::Resolved,
130 }
131 }
132
133 #[test]
134 fn meta_hash_is_stable() {
135 let h = meta_hash(&sample(), &sample_lineage());
138 assert_eq!(h, "25a62fac4d9e37cd");
139 assert_eq!(h.len(), 16);
140 assert_eq!(h, meta_hash(&sample(), &sample_lineage()));
141 }
142
143 #[test]
144 fn art_hash_is_stable_and_empty_without_art() {
145 let h = art_hash(&sample());
146 assert_eq!(h.len(), 16);
147 assert_eq!(h, art_hash(&sample()));
148
149 let mut bare = sample();
150 bare.image_large_url = String::new();
151 bare.image_url = String::new();
152 bare.video_cover_url = String::new();
153 assert_eq!(art_hash(&bare), "");
154 }
155
156 #[test]
157 fn art_url_hash_is_stable_and_empty_for_empty_url() {
158 assert_eq!(art_url_hash(""), "");
159 let h = art_url_hash("https://cdn1.suno.ai/video_cover.mp4");
160 assert_eq!(h.len(), 16);
161 assert_eq!(h, art_url_hash("https://cdn1.suno.ai/video_cover.mp4"));
162 assert_ne!(h, art_url_hash("https://cdn1.suno.ai/other.mp4"));
163 assert_eq!(
165 art_hash(&sample()),
166 art_url_hash(sample().selected_image_url().unwrap())
167 );
168 }
169
170 #[test]
171 fn meta_hash_ignores_path_only_fields() {
172 let lineage = sample_lineage();
173 let mut other = sample();
174 other.display_name = "Someone Else".to_owned();
175 assert_eq!(meta_hash(&sample(), &lineage), meta_hash(&other, &lineage));
176 }
177
178 #[test]
179 fn meta_hash_changes_when_a_content_field_changes() {
180 let lineage = sample_lineage();
181 let base = meta_hash(&sample(), &lineage);
182 for mutate in [
184 |c: &mut Clip| c.title = "Different".to_owned(),
185 |c: &mut Clip| c.tags = "lofi".to_owned(),
186 |c: &mut Clip| c.image_large_url = "https://cdn1.suno.ai/new.jpeg".to_owned(),
187 |c: &mut Clip| c.handle = "bob".to_owned(),
188 |c: &mut Clip| c.lyrics = "new words".to_owned(),
189 ] {
190 let mut clip = sample();
191 mutate(&mut clip);
192 assert_ne!(meta_hash(&clip, &lineage), base);
193 }
194 for mutate in [
196 |l: &mut LineageContext| l.parent_id = "other-parent".to_owned(),
197 |l: &mut LineageContext| l.root_id = "other-root".to_owned(),
198 |l: &mut LineageContext| l.root_title = "Other Album".to_owned(),
199 |l: &mut LineageContext| l.edge_type = Some(EdgeType::Cover),
200 ] {
201 let mut lin = sample_lineage();
202 mutate(&mut lin);
203 assert_ne!(meta_hash(&sample(), &lin), base);
204 }
205 }
206
207 #[test]
208 fn art_hash_tracks_the_selected_url_in_preference_order() {
209 let mut clip = sample();
210 let large = art_hash(&clip);
211 clip.image_large_url = String::new();
212 let standard = art_hash(&clip);
213 assert_ne!(large, standard);
214 clip.image_url = String::new();
215 clip.video_cover_url = "https://cdn1.suno.ai/video_cover.jpeg".to_owned();
216 let video = art_hash(&clip);
217 assert_ne!(standard, video);
218 }
219
220 #[test]
221 fn content_hash_is_stable_and_tracks_any_change() {
222 let text = "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:60,One\nA/One.flac\n";
223 let h = content_hash(text);
224 assert_eq!(h.len(), 16);
225 assert_eq!(h, content_hash(text), "same text hashes the same");
226 assert_ne!(
228 h,
229 content_hash("#EXTM3U\n#PLAYLIST:Other\n#EXTINF:60,One\nA/One.flac\n")
230 );
231 assert_ne!(
232 h,
233 content_hash("#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:61,One\nA/One.flac\n")
234 );
235 }
236}