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 const SYNCED_LRC_VERSION: u32 = 2;
95
96pub fn synced_lrc_source_hash(clip_id: &str) -> String {
106 content_hash(&format!("synced-lrc/v{SYNCED_LRC_VERSION}/{clip_id}"))
107}
108
109pub fn art_hash(clip: &Clip) -> String {
113 art_url_hash(clip.selected_image_url().unwrap_or(""))
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::lineage::ResolveStatus;
120
121 fn sample() -> Clip {
122 Clip {
123 title: "Electric Storm".to_owned(),
124 tags: "ambient, cinematic".to_owned(),
125 image_large_url: "https://cdn1.suno.ai/image_large_abc.jpeg".to_owned(),
126 image_url: "https://cdn1.suno.ai/image_abc.jpeg".to_owned(),
127 video_cover_url: String::new(),
128 root_ancestor_id: "root-1".to_owned(),
129 lineage_status: "continuation".to_owned(),
130 album_title: "Weather Series".to_owned(),
131 prompt: "an orchestral storm".to_owned(),
132 lyrics: "thunder rolls\nover the plains".to_owned(),
133 gpt_description_prompt: "stormy".to_owned(),
134 handle: "alice".to_owned(),
135 display_name: "Alice".to_owned(),
136 ..Default::default()
137 }
138 }
139
140 fn sample_lineage() -> LineageContext {
143 LineageContext {
144 root_id: "root-1".to_owned(),
145 root_title: "Weather Series".to_owned(),
146 parent_id: "parent-1".to_owned(),
147 edge_type: Some(EdgeType::Extend),
148 status: ResolveStatus::Resolved,
149 }
150 }
151
152 #[test]
153 fn meta_hash_is_stable() {
154 let h = meta_hash(&sample(), &sample_lineage());
157 assert_eq!(h, "25a62fac4d9e37cd");
158 assert_eq!(h.len(), 16);
159 assert_eq!(h, meta_hash(&sample(), &sample_lineage()));
160 }
161
162 #[test]
163 fn art_hash_is_stable_and_empty_without_art() {
164 let h = art_hash(&sample());
165 assert_eq!(h.len(), 16);
166 assert_eq!(h, art_hash(&sample()));
167
168 let mut bare = sample();
169 bare.image_large_url = String::new();
170 bare.image_url = String::new();
171 bare.video_cover_url = String::new();
172 assert_eq!(art_hash(&bare), "");
173 }
174
175 #[test]
176 fn art_url_hash_is_stable_and_empty_for_empty_url() {
177 assert_eq!(art_url_hash(""), "");
178 let h = art_url_hash("https://cdn1.suno.ai/video_cover.mp4");
179 assert_eq!(h.len(), 16);
180 assert_eq!(h, art_url_hash("https://cdn1.suno.ai/video_cover.mp4"));
181 assert_ne!(h, art_url_hash("https://cdn1.suno.ai/other.mp4"));
182 assert_eq!(
184 art_hash(&sample()),
185 art_url_hash(sample().selected_image_url().unwrap())
186 );
187 }
188
189 #[test]
190 fn meta_hash_ignores_path_only_fields() {
191 let lineage = sample_lineage();
192 let mut other = sample();
193 other.display_name = "Someone Else".to_owned();
194 assert_eq!(meta_hash(&sample(), &lineage), meta_hash(&other, &lineage));
195 }
196
197 #[test]
198 fn meta_hash_changes_when_a_content_field_changes() {
199 let lineage = sample_lineage();
200 let base = meta_hash(&sample(), &lineage);
201 for mutate in [
203 |c: &mut Clip| c.title = "Different".to_owned(),
204 |c: &mut Clip| c.tags = "lofi".to_owned(),
205 |c: &mut Clip| c.image_large_url = "https://cdn1.suno.ai/new.jpeg".to_owned(),
206 |c: &mut Clip| c.handle = "bob".to_owned(),
207 |c: &mut Clip| c.lyrics = "new words".to_owned(),
208 ] {
209 let mut clip = sample();
210 mutate(&mut clip);
211 assert_ne!(meta_hash(&clip, &lineage), base);
212 }
213 for mutate in [
215 |l: &mut LineageContext| l.parent_id = "other-parent".to_owned(),
216 |l: &mut LineageContext| l.root_id = "other-root".to_owned(),
217 |l: &mut LineageContext| l.root_title = "Other Album".to_owned(),
218 |l: &mut LineageContext| l.edge_type = Some(EdgeType::Cover),
219 ] {
220 let mut lin = sample_lineage();
221 mutate(&mut lin);
222 assert_ne!(meta_hash(&sample(), &lin), base);
223 }
224 }
225
226 #[test]
227 fn art_hash_tracks_the_selected_url_in_preference_order() {
228 let mut clip = sample();
229 let large = art_hash(&clip);
230 clip.image_large_url = String::new();
231 let standard = art_hash(&clip);
232 assert_ne!(large, standard);
233 clip.image_url = String::new();
234 clip.video_cover_url = "https://cdn1.suno.ai/video_cover.jpeg".to_owned();
235 let video = art_hash(&clip);
236 assert_ne!(standard, video);
237 }
238
239 #[test]
240 fn content_hash_is_stable_and_tracks_any_change() {
241 let text = "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:60,One\nA/One.flac\n";
242 let h = content_hash(text);
243 assert_eq!(h.len(), 16);
244 assert_eq!(h, content_hash(text), "same text hashes the same");
245 assert_ne!(
247 h,
248 content_hash("#EXTM3U\n#PLAYLIST:Other\n#EXTINF:60,One\nA/One.flac\n")
249 );
250 assert_ne!(
251 h,
252 content_hash("#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:61,One\nA/One.flac\n")
253 );
254 }
255
256 #[test]
257 fn synced_lrc_source_hash_is_stable_per_clip_and_never_empty() {
258 let a = synced_lrc_source_hash("clip-a");
259 assert_eq!(a.len(), 16);
260 assert_eq!(a, synced_lrc_source_hash("clip-a"), "stable per clip id");
261 assert_ne!(a, synced_lrc_source_hash("clip-b"));
264 assert!(!a.is_empty());
265 }
266}