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