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 field choices mirror the reference integration (ha-suno `clip_meta_hash`
9//! and `image_url_hash`): they capture everything that affects file *content*,
10//! and deliberately exclude path-affecting fields like `display_name`, since a
11//! path change is detected as a rename, not a retag.
12
13use std::hash::Hasher;
14
15use crate::lineage::{EdgeType, LineageContext};
16use crate::model::Clip;
17
18/// A short, stable hex digest of `bytes` (FNV-1a, 64-bit).
19fn digest(bytes: &[u8]) -> String {
20    let mut hasher = fnv::FnvHasher::default();
21    hasher.write(bytes);
22    format!("{:016x}", hasher.finish())
23}
24
25/// A stable sentinel over an arbitrary generated text artefact.
26///
27/// Used for playlists, whose `.m3u8` body is generated rather than fetched: the
28/// hash is taken over the **full rendered text**, so the playlist name, the
29/// member order, and every member's relative path, title, and duration all feed
30/// it (HARDENING B1: a change to anything that ends up in the file changes the
31/// hash and so triggers a rewrite). Because the render is deterministic, the
32/// hash is stable across runs and platforms.
33pub fn content_hash(text: &str) -> String {
34    digest(text.as_bytes())
35}
36
37/// A sentinel for the clip's tag-bearing metadata and chosen art.
38///
39/// Covers every field that affects file *content*: title, tags, the selected
40/// art URL, video cover, the prompt, the lyrics and description, the account
41/// handle, and the *resolved* lineage that gets embedded (immediate parent and
42/// edge, root id and title, the album the clip folders under, and the release
43/// year the album groups under), so a change to any of them is detected as a
44/// needed retag. This takes the resolved [`LineageContext`] rather than the raw
45/// feed fields precisely because those resolved values are what end up in the
46/// file (HARDENING B1: if a value is embedded, it is in the change hash), so a
47/// retitle, re-point, album move, or year correction triggers a retag.
48///
49/// Path-affecting fields such as `display_name` are excluded on purpose: a path
50/// change is a rename, detected by comparing the rendered path with the stored
51/// one. `title` is included so a title change triggers both a rename and a
52/// retag.
53pub 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
75/// A stable digest of an artifact source URL (FNV-1a), or the empty string when
76/// `url` is empty.
77///
78/// Shared by [`art_hash`] (the embedded static cover) and the external animated
79/// cover sidecar, whose rewrite detection keys on the clip's `video_cover_url`
80/// rather than the selected image. Keeping both on the one helper means an empty
81/// URL always maps to the empty sentinel, the value reconcile reads as "no such
82/// artifact this run".
83pub 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
91/// The change-detection version for the synced `.lrc` body. Bump this when the
92/// rendered `.lrc` format changes so existing sidecars are rewritten on the next
93/// run (their stored hash then no longer matches, exactly as edited content
94/// would move a [`content_hash`]).
95pub const SYNCED_LRC_VERSION: u32 = 2;
96
97/// A stable per-clip source sentinel for the synced `.lrc` sidecar.
98///
99/// Suno's forced alignment for a given clip is immutable (the audio and its
100/// lyrics are fixed once generated), so the sidecar's rewrite detection keys on
101/// the clip id plus the render [`SYNCED_LRC_VERSION`] rather than the fetched
102/// body. This lets reconcile skip an unchanged clip WITHOUT a network fetch (the
103/// timed body is resolved only when a write is actually planned), while a
104/// version bump rewrites every sidecar. It mirrors how the cover sidecars key on
105/// their source URL rather than the fetched bytes ("the hash tracks the source").
106pub fn synced_lrc_source_hash(clip_id: &str) -> String {
107    content_hash(&format!("synced-lrc/v{SYNCED_LRC_VERSION}/{clip_id}"))
108}
109
110/// A sentinel for the embedded cover art: a digest of the selected art URL, or
111/// the empty string when the clip carries no art. A mismatch against the
112/// manifest means the file on disk holds stale art even if its tags are current.
113pub 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    /// The resolved lineage embedded alongside [`sample`]: an extension of a
142    /// parent under the "Weather Series" root, created in 2023.
143    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        // Golden value: a change here means the sentinel encoding changed and
157        // every existing manifest would see a spurious retag. Change with care.
158        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        // art_hash routes the selected image URL through the same helper.
185        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        // Clip-side content fields.
204        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        // Resolved-lineage values that get embedded must also move the hash.
216        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        // A different name, order, path, title, or duration changes the digest.
249        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        // Distinct clips get distinct sentinels; none is the empty ("absent")
265        // value, so a desired synced `.lrc` is never mistaken for "no artifact".
266        assert_ne!(a, synced_lrc_source_hash("clip-b"));
267        assert!(!a.is_empty());
268    }
269}