suno_core/hash.rs
1//! Stable content sentinels for change detection.
2//!
3//! Reconcile compares a clip's current [`meta_hash`]/[`art_hash`] against the
4//! manifest to decide whether a file needs re-tagging. The hashes must be stable
5//! across runs, versions, and platforms, so they use FNV-1a over a fixed field
6//! encoding rather than the standard library's deliberately unspecified hasher.
7//!
8//! The hash inputs are the exact fields the tag writer embeds: [`meta_hash`]
9//! hashes the resolved [`TrackMetadata`], and [`art_hash`] tracks the chosen art
10//! URL. Anything embedded in the file is therefore in a hash, so an upstream
11//! change to it triggers a retag; anything not embedded (path-affecting or
12//! sidecar-only fields such as the animated-cover URL) is excluded.
13
14use std::hash::{Hash, Hasher};
15
16use crate::ffmpeg::WebpEncodeSettings;
17use crate::lineage::LineageContext;
18use crate::model::Clip;
19use crate::tag::TrackMetadata;
20
21/// A short, stable hex digest of `bytes` (FNV-1a, 64-bit).
22fn digest(bytes: &[u8]) -> String {
23 let mut hasher = fnv::FnvHasher::default();
24 hasher.write(bytes);
25 format!("{:016x}", hasher.finish())
26}
27
28/// A stable sentinel over an arbitrary generated text artefact.
29///
30/// Used for playlists, whose `.m3u8` body is generated rather than fetched: the
31/// hash is taken over the **full rendered text**, so the playlist name, the
32/// member order, and every member's relative path, title, and duration all feed
33/// it (HARDENING B1: a change to anything that ends up in the file changes the
34/// hash and so triggers a rewrite). Because the render is deterministic, the
35/// hash is stable across runs and platforms.
36pub fn content_hash(text: &str) -> String {
37 digest(text.as_bytes())
38}
39
40/// A sentinel for the clip's embedded tag set.
41///
42/// Hashes the resolved [`TrackMetadata`] that is actually written into the file
43/// (title, artist, album, date/year, lyrics, prompt, model, handle, and the
44/// resolved lineage tags), so a change to any embedded tag — including the
45/// artist (`display_name`) and model label, which the old hand-listed field set
46/// omitted — is detected as a needed retag, while a change to a field that is
47/// *not* embedded in the audio (e.g. the animated-cover URL) is not. Taking
48/// [`TrackMetadata`] directly keeps this in lock-step with the tag writer
49/// (HARDENING B1: if a value is embedded, it is in the change hash), so a
50/// retitle, artist rename, re-point, album move, or year correction all trigger
51/// a retag. Chosen art is tracked separately by [`art_hash`].
52///
53/// A pure path change (e.g. one driven only by a field that renames but does
54/// not embed) is still handled as a rename, by comparing the rendered path with
55/// the stored one, not by this hash.
56pub fn meta_hash(clip: &Clip, lineage: &LineageContext) -> String {
57 let mut hasher = fnv::FnvHasher::default();
58 TrackMetadata::from_clip(clip, lineage).hash(&mut hasher);
59 format!("{:016x}", hasher.finish())
60}
61
62/// A stable digest of an artifact source URL (FNV-1a), or the empty string when
63/// `url` is empty.
64///
65/// Shared by [`art_hash`] (the embedded static cover) and the external animated
66/// cover sidecar, whose rewrite detection keys on the clip's `video_cover_url`
67/// rather than the selected image. Keeping both on the one helper means an empty
68/// URL always maps to the empty sentinel, the value reconcile reads as "no such
69/// artifact this run".
70pub fn art_url_hash(url: &str) -> String {
71 if url.is_empty() {
72 String::new()
73 } else {
74 digest(url.as_bytes())
75 }
76}
77
78/// A digest of a transcoded animated-cover source URL *and* the encode settings
79/// that shape its bytes, or the empty string when `url` is empty.
80///
81/// Unlike the raw `cover.mp4` (a verbatim copy, keyed on its URL alone via
82/// [`art_url_hash`]), the animated `cover.webp` and per-song `.webp` are a
83/// transcode: their bytes depend on quality, lossless, effort, frame-rate, and
84/// width as much as on the source. Folding those into the hash means a settings
85/// change (for example raising the default quality) re-encodes existing covers
86/// on the next run, exactly as a changed source URL does, rather than leaving
87/// them stale. An empty URL still maps to the empty "no artifact" sentinel.
88pub fn webp_art_hash(url: &str, settings: &WebpEncodeSettings) -> String {
89 if url.is_empty() {
90 return String::new();
91 }
92 let mut hasher = fnv::FnvHasher::default();
93 hasher.write(url.as_bytes());
94 hasher.write_u8(0);
95 hasher.write_u8(settings.quality);
96 hasher.write_u8(u8::from(settings.lossless));
97 hasher.write_u8(settings.compression_level);
98 hasher.write_u32(settings.max_fps);
99 match settings.max_width {
100 Some(width) => {
101 hasher.write_u8(1);
102 hasher.write_u32(width);
103 }
104 None => hasher.write_u8(0),
105 }
106 format!("{:016x}", hasher.finish())
107}
108
109/// The change-detection version for the synced `.lrc` body. Bump this when the
110/// rendered `.lrc` format changes so existing sidecars are rewritten on the next
111/// run (their stored hash then no longer matches, exactly as edited content
112/// would move a [`content_hash`]).
113pub const SYNCED_LRC_VERSION: u32 = 2;
114
115/// A stable per-clip source sentinel for the synced `.lrc` sidecar.
116///
117/// Suno's forced alignment for a given clip is immutable (the audio and its
118/// lyrics are fixed once generated), so the sidecar's rewrite detection keys on
119/// the clip id plus the render [`SYNCED_LRC_VERSION`] rather than the fetched
120/// body. This lets reconcile skip an unchanged clip WITHOUT a network fetch (the
121/// timed body is resolved only when a write is actually planned), while a
122/// version bump rewrites every sidecar. It mirrors how the cover sidecars key on
123/// their source URL rather than the fetched bytes ("the hash tracks the source").
124pub fn synced_lrc_source_hash(clip_id: &str) -> String {
125 content_hash(&format!("synced-lrc/v{SYNCED_LRC_VERSION}/{clip_id}"))
126}
127
128/// A sentinel for the embedded cover art: a digest of the selected art URL, or
129/// the empty string when the clip carries no art. A mismatch against the
130/// manifest means the file on disk holds stale art even if its tags are current.
131pub fn art_hash(clip: &Clip) -> String {
132 art_url_hash(clip.selected_image_url().unwrap_or(""))
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::lineage::{EdgeType, ResolveStatus};
139
140 fn sample() -> Clip {
141 Clip {
142 title: "Electric Storm".to_owned(),
143 tags: "ambient, cinematic".to_owned(),
144 image_large_url: "https://cdn1.suno.ai/image_large_abc.jpeg".to_owned(),
145 image_url: "https://cdn1.suno.ai/image_abc.jpeg".to_owned(),
146 video_cover_url: String::new(),
147 prompt: "an orchestral storm".to_owned(),
148 lyrics: "thunder rolls\nover the plains".to_owned(),
149 gpt_description_prompt: "stormy".to_owned(),
150 handle: "alice".to_owned(),
151 display_name: "Alice".to_owned(),
152 ..Default::default()
153 }
154 }
155
156 /// The resolved lineage embedded alongside [`sample`]: an extension of a
157 /// parent under the "Weather Series" root, created in 2023.
158 fn sample_lineage() -> LineageContext {
159 LineageContext {
160 root_id: "root-1".to_owned(),
161 root_title: "Weather Series".to_owned(),
162 root_date: "2023-05-01T00:00:00Z".to_owned(),
163 parent_id: "parent-1".to_owned(),
164 edge_type: Some(EdgeType::Extend),
165 status: ResolveStatus::Resolved,
166 }
167 }
168
169 #[test]
170 fn meta_hash_is_stable() {
171 // Golden value: a change here means the sentinel encoding changed and
172 // every existing manifest would see a spurious retag. Change with care.
173 let h = meta_hash(&sample(), &sample_lineage());
174 assert_eq!(h, "c247d31f60378b86");
175 assert_eq!(h.len(), 16);
176 assert_eq!(h, meta_hash(&sample(), &sample_lineage()));
177 }
178
179 #[test]
180 fn art_hash_is_stable_and_empty_without_art() {
181 let h = art_hash(&sample());
182 assert_eq!(h.len(), 16);
183 assert_eq!(h, art_hash(&sample()));
184
185 let mut bare = sample();
186 bare.image_large_url = String::new();
187 bare.image_url = String::new();
188 bare.video_cover_url = String::new();
189 assert_eq!(art_hash(&bare), "");
190 }
191
192 #[test]
193 fn art_url_hash_is_stable_and_empty_for_empty_url() {
194 assert_eq!(art_url_hash(""), "");
195 let h = art_url_hash("https://cdn1.suno.ai/video_cover.mp4");
196 assert_eq!(h.len(), 16);
197 assert_eq!(h, art_url_hash("https://cdn1.suno.ai/video_cover.mp4"));
198 assert_ne!(h, art_url_hash("https://cdn1.suno.ai/other.mp4"));
199 // art_hash routes the selected image URL through the same helper.
200 assert_eq!(
201 art_hash(&sample()),
202 art_url_hash(sample().selected_image_url().unwrap())
203 );
204 }
205
206 #[test]
207 fn webp_art_hash_tracks_url_and_every_encode_setting() {
208 use crate::ffmpeg::WebpEncodeSettings;
209 let url = "https://cdn1.suno.ai/video_cover.mp4";
210 let base = WebpEncodeSettings::default();
211 let h = webp_art_hash(url, &base);
212 assert_eq!(h.len(), 16);
213 assert_eq!(h, webp_art_hash(url, &base), "stable for the same inputs");
214 // Empty URL is the "no artifact" sentinel regardless of settings.
215 assert_eq!(webp_art_hash("", &base), "");
216 // A changed source URL re-transcodes.
217 assert_ne!(h, webp_art_hash("https://cdn1.suno.ai/other.mp4", &base));
218 // Every setting that shapes the output bytes moves the hash, so a
219 // settings change re-encodes an existing cover.
220 for mutate in [
221 |s: &mut WebpEncodeSettings| s.quality = s.quality.wrapping_sub(1),
222 |s: &mut WebpEncodeSettings| s.lossless = !s.lossless,
223 |s: &mut WebpEncodeSettings| s.compression_level = s.compression_level.wrapping_add(1),
224 |s: &mut WebpEncodeSettings| s.max_fps = s.max_fps.wrapping_add(1),
225 |s: &mut WebpEncodeSettings| s.max_width = Some(640),
226 ] {
227 let mut settings = base;
228 mutate(&mut settings);
229 assert_ne!(h, webp_art_hash(url, &settings));
230 }
231 }
232
233 #[test]
234 fn meta_hash_tracks_the_artist_and_model_but_not_sidecar_only_fields() {
235 let lineage = sample_lineage();
236 let base = meta_hash(&sample(), &lineage);
237 // The artist (`display_name`) and model label are embedded tags, so an
238 // upstream change to either must retag (#135) -- the old hand-listed
239 // hash omitted both, leaving stale tags on re-sync.
240 let mut artist = sample();
241 artist.display_name = "Someone Else".to_owned();
242 assert_ne!(meta_hash(&artist, &lineage), base);
243 let mut model = sample();
244 model.model_name = "chirp-v9".to_owned();
245 assert_ne!(meta_hash(&model, &lineage), base);
246 // The animated-cover URL is a sidecar source, not an audio tag: it has
247 // its own hash and must not force a needless audio retag (#136).
248 let mut cover = sample();
249 cover.video_cover_url = "https://cdn1.suno.ai/new_cover.mp4".to_owned();
250 assert_eq!(meta_hash(&cover, &lineage), base);
251 }
252
253 #[test]
254 fn meta_hash_changes_when_a_content_field_changes() {
255 let lineage = sample_lineage();
256 let base = meta_hash(&sample(), &lineage);
257 // Clip-side content fields. (Art lives in `art_hash`, not here.)
258 for mutate in [
259 |c: &mut Clip| c.title = "Different".to_owned(),
260 |c: &mut Clip| c.tags = "lofi".to_owned(),
261 |c: &mut Clip| c.handle = "bob".to_owned(),
262 |c: &mut Clip| c.lyrics = "new words".to_owned(),
263 ] {
264 let mut clip = sample();
265 mutate(&mut clip);
266 assert_ne!(meta_hash(&clip, &lineage), base);
267 }
268 // Resolved-lineage values that get embedded must also move the hash.
269 for mutate in [
270 |l: &mut LineageContext| l.parent_id = "other-parent".to_owned(),
271 |l: &mut LineageContext| l.root_id = "other-root".to_owned(),
272 |l: &mut LineageContext| l.root_title = "Other Album".to_owned(),
273 |l: &mut LineageContext| l.edge_type = Some(EdgeType::Cover),
274 |l: &mut LineageContext| l.root_date = "2099-01-01T00:00:00Z".to_owned(),
275 ] {
276 let mut lin = sample_lineage();
277 mutate(&mut lin);
278 assert_ne!(meta_hash(&sample(), &lin), base);
279 }
280 }
281
282 #[test]
283 fn art_hash_tracks_the_selected_url_in_preference_order() {
284 let mut clip = sample();
285 let large = art_hash(&clip);
286 clip.image_large_url = String::new();
287 let standard = art_hash(&clip);
288 assert_ne!(large, standard);
289 clip.image_url = String::new();
290 clip.video_cover_url = "https://cdn1.suno.ai/video_cover.jpeg".to_owned();
291 let video = art_hash(&clip);
292 assert_ne!(standard, video);
293 }
294
295 #[test]
296 fn content_hash_is_stable_and_tracks_any_change() {
297 let text = "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:60,One\nA/One.flac\n";
298 let h = content_hash(text);
299 assert_eq!(h.len(), 16);
300 assert_eq!(h, content_hash(text), "same text hashes the same");
301 // A different name, order, path, title, or duration changes the digest.
302 assert_ne!(
303 h,
304 content_hash("#EXTM3U\n#PLAYLIST:Other\n#EXTINF:60,One\nA/One.flac\n")
305 );
306 assert_ne!(
307 h,
308 content_hash("#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:61,One\nA/One.flac\n")
309 );
310 }
311
312 #[test]
313 fn synced_lrc_source_hash_is_stable_per_clip_and_never_empty() {
314 let a = synced_lrc_source_hash("clip-a");
315 assert_eq!(a.len(), 16);
316 assert_eq!(a, synced_lrc_source_hash("clip-a"), "stable per clip id");
317 // Distinct clips get distinct sentinels; none is the empty ("absent")
318 // value, so a desired synced `.lrc` is never mistaken for "no artifact".
319 assert_ne!(a, synced_lrc_source_hash("clip-b"));
320 assert!(!a.is_empty());
321 }
322}