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