Skip to main content

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::lineage::LineageContext;
17use crate::model::Clip;
18use crate::tag::TrackMetadata;
19use crate::vocab::WebpEncodeSettings;
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 member
32/// order, and every member's relative path, title, and duration all feed it, and
33/// any change that reaches the file triggers a rewrite. The render is
34/// deterministic, so the hash is stable across runs and platforms.
35pub fn content_hash(text: &str) -> String {
36    digest(text.as_bytes())
37}
38
39/// A sentinel for the clip's embedded tag set.
40///
41/// Hashes the resolved [`TrackMetadata`] actually written into the file (title,
42/// artist, album, date/year, lyrics, prompt, model, handle, and the resolved
43/// lineage tags), so a change to any embedded tag triggers a retag while a
44/// change to a non-embedded field (e.g. the animated-cover URL) does not. Taking
45/// [`TrackMetadata`] directly keeps it in lock-step with the tag writer: if a
46/// value is embedded, it is in this hash. Chosen art is tracked separately by
47/// [`art_hash`], and the embedded aligned lyrics (Suno's fetched alignment, not
48/// `clip.lyrics`) by
49/// [`ManifestEntry::embedded_lyrics_hash`](crate::manifest::ManifestEntry::embedded_lyrics_hash);
50/// both are the invariant's two exceptions, embedded but fingerprinted outside
51/// this hash.
52///
53/// A pure path change is handled as a rename, by comparing the rendered path
54/// with the stored one, not by this hash.
55pub fn meta_hash(clip: &Clip, lineage: &LineageContext) -> String {
56    let mut hasher = fnv::FnvHasher::default();
57    TrackMetadata::from_clip(clip, lineage).hash(&mut hasher);
58    format!("{:016x}", hasher.finish())
59}
60
61/// A stable digest of an artifact source URL (FNV-1a), or the empty string when
62/// `url` is empty.
63///
64/// Shared by [`art_hash`] (the embedded static cover) and the external animated
65/// cover sidecar, whose rewrite detection keys on the clip's `video_cover_url`
66/// rather than the selected image. Keeping both on the one helper means an empty
67/// URL always maps to the empty sentinel, the value reconcile reads as "no such
68/// artifact this run".
69pub fn art_url_hash(url: &str) -> String {
70    if url.is_empty() {
71        String::new()
72    } else {
73        digest(url.as_bytes())
74    }
75}
76
77/// A digest of a transcoded animated-cover source URL *and* the encode settings
78/// that shape its bytes, or the empty string when `url` is empty.
79///
80/// Unlike the raw `cover.mp4` (a verbatim copy, keyed on its URL alone via
81/// [`art_url_hash`]), the animated `cover.webp` and per-song `.webp` are a
82/// transcode: their bytes depend on quality, lossless, effort, frame-rate, and
83/// width as much as on the source. Folding those into the hash means a settings
84/// change (for example raising the default quality) re-encodes existing covers
85/// on the next run, exactly as a changed source URL does, rather than leaving
86/// them stale. An empty URL still maps to the empty "no artifact" sentinel.
87pub fn webp_art_hash(url: &str, settings: &WebpEncodeSettings) -> String {
88    if url.is_empty() {
89        return String::new();
90    }
91    let mut hasher = fnv::FnvHasher::default();
92    hasher.write(url.as_bytes());
93    hasher.write_u8(0);
94    write_webp_settings(&mut hasher, settings);
95    format!("{:016x}", hasher.finish())
96}
97
98/// Fold the WebP encode `settings` into `hasher` in a fixed field order. Shared
99/// by [`webp_art_hash`] and [`embedded_art_hash`] so the settings contribution
100/// to both sentinels stays bit-identical.
101fn write_webp_settings(hasher: &mut fnv::FnvHasher, settings: &WebpEncodeSettings) {
102    hasher.write_u8(settings.quality);
103    hasher.write_u8(u8::from(settings.lossless));
104    hasher.write_u8(settings.compression_level);
105    hasher.write_u32(settings.max_fps);
106    match settings.max_width {
107        Some(width) => {
108            hasher.write_u8(1);
109            hasher.write_u32(width);
110        }
111        None => hasher.write_u8(0),
112    }
113}
114
115/// The change-detection version for the synced `.lrc` body. Bump this when the
116/// rendered `.lrc` format changes so existing sidecars are rewritten on the next
117/// run (their stored hash then no longer matches, exactly as edited content
118/// would move a [`content_hash`]).
119pub const SYNCED_LRC_VERSION: u32 = 3;
120
121/// A stable per-clip source sentinel for the synced `.lrc` sidecar.
122///
123/// Suno's forced alignment for a given clip is immutable (the audio and its
124/// lyrics are fixed once generated), so the sidecar's rewrite detection keys on
125/// the clip id plus the render [`SYNCED_LRC_VERSION`] rather than the fetched
126/// body. This lets reconcile skip an unchanged clip WITHOUT a network fetch (the
127/// timed body is resolved only when a write is actually planned), while a
128/// version bump rewrites every sidecar. It mirrors how the cover sidecars key on
129/// their source URL rather than the fetched bytes ("the hash tracks the source").
130pub fn synced_lrc_source_hash(clip_id: &str) -> String {
131    content_hash(&format!("synced-lrc/v{SYNCED_LRC_VERSION}/{clip_id}"))
132}
133
134/// A stable per-clip source sentinel for the deferred `.lyrics.txt` sidecar.
135///
136/// The plain-text sidecar is resolved from the fetched alignment (or, when the
137/// feed carries them, `clip.lyrics`) only when a write is actually planned, so
138/// its desired artifact carries this placeholder until
139/// [`apply_synced_lrc`](crate::apply_synced_lrc) fills the real content hash,
140/// exactly as [`synced_lrc_source_hash`] defers the `.lrc`. A distinct salt
141/// keeps it from ever colliding with the `.lrc` sentinel for the same clip.
142pub fn lyrics_txt_source_hash(clip_id: &str) -> String {
143    content_hash(&format!("lyrics-txt/v{SYNCED_LRC_VERSION}/{clip_id}"))
144}
145
146/// A sentinel for the embedded cover art: a digest of the selected art URL, or
147/// the empty string when the clip carries no art. A mismatch against the
148/// manifest means the file on disk holds stale art even if its tags are current.
149pub fn art_hash(clip: &Clip) -> String {
150    art_url_hash(clip.selected_image_url().unwrap_or(""))
151}
152
153/// The embedded-cover sentinel that accounts for the animated-WebP embed.
154///
155/// When `embed_animated` is set and the clip has a `video_cover_url`, the file
156/// embeds a bounded animated WebP, so its identity is the source URL, the encode
157/// `settings`, and the static image URL (folded in because the WebP falls back
158/// to that static JPEG when it will not fit, and a later static-art change must
159/// still re-tag a fallen-back file). It hashes the embed intent, not the runtime
160/// fit outcome, so a pure dry-run cannot know the encoded size and a fit fallback
161/// does not churn. Otherwise (feature off, no preview, or an ALAC target, which
162/// cannot embed WebP) it is the plain static [`art_hash`].
163///
164/// Gating on `video_cover_url` presence scopes an enable-time re-tag to clips
165/// that have a preview rather than the whole library. A preview is an immutable
166/// asset the v3 feed returns intact, so this does not flap, and a stale-preview
167/// fallback is bounded tag churn, never data loss.
168pub fn embedded_art_hash(
169    clip: &Clip,
170    embed_animated: bool,
171    settings: &WebpEncodeSettings,
172) -> String {
173    if embed_animated && !clip.video_cover_url.is_empty() {
174        let mut hasher = fnv::FnvHasher::default();
175        hasher.write(b"webp-embed\0");
176        hasher.write(clip.video_cover_url.as_bytes());
177        hasher.write_u8(0);
178        hasher.write(clip.selected_image_url().unwrap_or("").as_bytes());
179        hasher.write_u8(0);
180        write_webp_settings(&mut hasher, settings);
181        format!("{:016x}", hasher.finish())
182    } else {
183        art_hash(clip)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::lineage::{EdgeType, ResolveStatus};
191
192    fn sample() -> Clip {
193        Clip {
194            title: "Electric Storm".to_owned(),
195            tags: "ambient, cinematic".to_owned(),
196            image_large_url: "https://cdn1.suno.ai/image_large_abc.jpeg".to_owned(),
197            image_url: "https://cdn1.suno.ai/image_abc.jpeg".to_owned(),
198            video_cover_url: String::new(),
199            prompt: "an orchestral storm".to_owned(),
200            lyrics: "thunder rolls\nover the plains".to_owned(),
201            gpt_description_prompt: "stormy".to_owned(),
202            handle: "alice".to_owned(),
203            display_name: "Alice".to_owned(),
204            ..Default::default()
205        }
206    }
207
208    /// The resolved lineage embedded alongside [`sample`]: an extension of a
209    /// parent under the "Weather Series" root, created in 2023.
210    fn sample_lineage() -> LineageContext {
211        LineageContext {
212            root_id: "root-1".to_owned(),
213            root_title: "Weather Series".to_owned(),
214            root_date: "2023-05-01T00:00:00Z".to_owned(),
215            parent_id: "parent-1".to_owned(),
216            edge_type: Some(EdgeType::Extend),
217            status: ResolveStatus::Resolved,
218            track: 0,
219            track_total: 0,
220        }
221    }
222
223    #[test]
224    fn meta_hash_is_stable() {
225        // Golden value: a change here means the sentinel encoding changed and
226        // every existing manifest would see a spurious retag. Change with care.
227        let h = meta_hash(&sample(), &sample_lineage());
228        assert_eq!(h, "d355c0c463e1f96c");
229        assert_eq!(h.len(), 16);
230        assert_eq!(h, meta_hash(&sample(), &sample_lineage()));
231    }
232
233    #[test]
234    fn art_hash_is_stable_and_empty_without_art() {
235        let h = art_hash(&sample());
236        assert_eq!(h.len(), 16);
237        assert_eq!(h, art_hash(&sample()));
238
239        let mut bare = sample();
240        bare.image_large_url = String::new();
241        bare.image_url = String::new();
242        bare.video_cover_url = String::new();
243        assert_eq!(art_hash(&bare), "");
244    }
245
246    #[test]
247    fn art_url_hash_is_stable_and_empty_for_empty_url() {
248        assert_eq!(art_url_hash(""), "");
249        let h = art_url_hash("https://cdn1.suno.ai/video_cover.mp4");
250        assert_eq!(h.len(), 16);
251        assert_eq!(h, art_url_hash("https://cdn1.suno.ai/video_cover.mp4"));
252        assert_ne!(h, art_url_hash("https://cdn1.suno.ai/other.mp4"));
253        // art_hash routes the selected image URL through the same helper.
254        assert_eq!(
255            art_hash(&sample()),
256            art_url_hash(sample().selected_image_url().unwrap())
257        );
258    }
259
260    #[test]
261    fn webp_art_hash_tracks_url_and_every_encode_setting() {
262        use crate::vocab::WebpEncodeSettings;
263        let url = "https://cdn1.suno.ai/video_cover.mp4";
264        let base = WebpEncodeSettings::default();
265        let h = webp_art_hash(url, &base);
266        assert_eq!(h.len(), 16);
267        assert_eq!(h, webp_art_hash(url, &base), "stable for the same inputs");
268        // Empty URL is the "no artifact" sentinel regardless of settings.
269        assert_eq!(webp_art_hash("", &base), "");
270        // A changed source URL re-transcodes.
271        assert_ne!(h, webp_art_hash("https://cdn1.suno.ai/other.mp4", &base));
272        // Every setting that shapes the output bytes moves the hash, so a
273        // settings change re-encodes an existing cover.
274        for mutate in [
275            |s: &mut WebpEncodeSettings| s.quality = s.quality.wrapping_sub(1),
276            |s: &mut WebpEncodeSettings| s.lossless = !s.lossless,
277            |s: &mut WebpEncodeSettings| s.compression_level = s.compression_level.wrapping_add(1),
278            |s: &mut WebpEncodeSettings| s.max_fps = s.max_fps.wrapping_add(1),
279            |s: &mut WebpEncodeSettings| s.max_width = None,
280        ] {
281            let mut settings = base;
282            mutate(&mut settings);
283            assert_ne!(h, webp_art_hash(url, &settings));
284        }
285    }
286
287    #[test]
288    fn meta_hash_tracks_the_artist_and_model_but_not_sidecar_only_fields() {
289        let lineage = sample_lineage();
290        let base = meta_hash(&sample(), &lineage);
291        // The artist (`display_name`) and model label are embedded tags, so an
292        // upstream change to either must retag.
293        let mut artist = sample();
294        artist.display_name = "Someone Else".to_owned();
295        assert_ne!(meta_hash(&artist, &lineage), base);
296        let mut model = sample();
297        model.model_name = "chirp-v9".to_owned();
298        assert_ne!(meta_hash(&model, &lineage), base);
299        // The animated-cover URL is a sidecar source, not an audio tag: it has
300        // its own hash and must not force a needless audio retag.
301        let mut cover = sample();
302        cover.video_cover_url = "https://cdn1.suno.ai/new_cover.mp4".to_owned();
303        assert_eq!(meta_hash(&cover, &lineage), base);
304    }
305
306    #[test]
307    fn meta_hash_changes_when_a_content_field_changes() {
308        let lineage = sample_lineage();
309        let base = meta_hash(&sample(), &lineage);
310        // Clip-side content fields. (Art lives in `art_hash`, not here.)
311        for mutate in [
312            |c: &mut Clip| c.title = "Different".to_owned(),
313            |c: &mut Clip| c.tags = "lofi".to_owned(),
314            |c: &mut Clip| c.handle = "bob".to_owned(),
315            |c: &mut Clip| c.lyrics = "new words".to_owned(),
316        ] {
317            let mut clip = sample();
318            mutate(&mut clip);
319            assert_ne!(meta_hash(&clip, &lineage), base);
320        }
321        // Resolved-lineage values that get embedded must also move the hash.
322        for mutate in [
323            |l: &mut LineageContext| l.parent_id = "other-parent".to_owned(),
324            |l: &mut LineageContext| l.root_id = "other-root".to_owned(),
325            |l: &mut LineageContext| l.root_title = "Other Album".to_owned(),
326            |l: &mut LineageContext| l.edge_type = Some(EdgeType::Cover),
327            |l: &mut LineageContext| l.root_date = "2099-01-01T00:00:00Z".to_owned(),
328        ] {
329            let mut lin = sample_lineage();
330            mutate(&mut lin);
331            assert_ne!(meta_hash(&sample(), &lin), base);
332        }
333    }
334
335    #[test]
336    fn art_hash_tracks_the_selected_url_in_preference_order() {
337        let mut clip = sample();
338        let large = art_hash(&clip);
339        clip.image_large_url = String::new();
340        let standard = art_hash(&clip);
341        assert_ne!(large, standard);
342        clip.image_url = String::new();
343        clip.video_cover_url = "https://cdn1.suno.ai/video_cover.jpeg".to_owned();
344        let video = art_hash(&clip);
345        assert_ne!(standard, video);
346    }
347
348    #[test]
349    fn content_hash_is_stable_and_tracks_any_change() {
350        let text = "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:60,One\nA/One.flac\n";
351        let h = content_hash(text);
352        assert_eq!(h.len(), 16);
353        assert_eq!(h, content_hash(text), "same text hashes the same");
354        // A different name, order, path, title, or duration changes the digest.
355        assert_ne!(
356            h,
357            content_hash("#EXTM3U\n#PLAYLIST:Other\n#EXTINF:60,One\nA/One.flac\n")
358        );
359        assert_ne!(
360            h,
361            content_hash("#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:61,One\nA/One.flac\n")
362        );
363    }
364
365    #[test]
366    fn synced_lrc_source_hash_is_stable_per_clip_and_never_empty() {
367        let a = synced_lrc_source_hash("clip-a");
368        assert_eq!(a.len(), 16);
369        assert_eq!(a, synced_lrc_source_hash("clip-a"), "stable per clip id");
370        // Distinct clips get distinct sentinels; none is the empty ("absent")
371        // value, so a desired synced `.lrc` is never mistaken for "no artifact".
372        assert_ne!(a, synced_lrc_source_hash("clip-b"));
373        assert!(!a.is_empty());
374    }
375
376    #[test]
377    fn lyrics_txt_source_hash_is_stable_per_clip_and_distinct_from_lrc() {
378        let a = lyrics_txt_source_hash("clip-a");
379        assert_eq!(a.len(), 16);
380        assert_eq!(a, lyrics_txt_source_hash("clip-a"), "stable per clip id");
381        // Distinct clips get distinct sentinels; none is the empty ("absent")
382        // value, so a desired `.lyrics.txt` is never mistaken for "no artifact".
383        assert_ne!(a, lyrics_txt_source_hash("clip-b"));
384        assert!(!a.is_empty());
385        // The distinct salt keeps it from colliding with the `.lrc` sentinel for
386        // the SAME clip, so a clip that desires both sidecars gets two artifacts.
387        assert_ne!(
388            a,
389            synced_lrc_source_hash("clip-a"),
390            "the `.lyrics.txt` sentinel never collides with the `.lrc` one"
391        );
392    }
393}