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 and description, the account handle, and the
41/// *resolved* lineage that gets embedded (immediate parent and edge, root id and
42/// title, and the album the clip folders under) — so a change to any of them is
43/// detected as a needed retag. This takes the resolved [`LineageContext`] rather
44/// than the raw feed fields precisely because those resolved values are what end
45/// up in the file (HARDENING B1: if a value is embedded, it is in the change
46/// hash), so a retitle, re-point, or album move triggers a retag.
47///
48/// Path-affecting fields such as `display_name` are excluded on purpose: a path
49/// change is a rename, detected by comparing the rendered path with the stored
50/// one. `title` is included so a title change triggers both a rename and a
51/// retag.
52pub fn meta_hash(clip: &Clip, lineage: &LineageContext) -> String {
53    let edge_label = lineage.edge_type.map(EdgeType::label).unwrap_or("");
54    let fields = format!(
55        "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
56        clip.title,
57        clip.tags,
58        clip.selected_image_url().unwrap_or(""),
59        clip.video_cover_url,
60        lineage.parent_id,
61        edge_label,
62        lineage.root_id,
63        lineage.root_title,
64        lineage.album(&clip.title),
65        clip.prompt,
66        clip.gpt_description_prompt,
67        clip.handle,
68    );
69    digest(fields.as_bytes())
70}
71
72/// A stable digest of an artifact source URL (FNV-1a), or the empty string when
73/// `url` is empty.
74///
75/// Shared by [`art_hash`] (the embedded static cover) and the external animated
76/// cover sidecar, whose rewrite detection keys on the clip's `video_cover_url`
77/// rather than the selected image. Keeping both on the one helper means an empty
78/// URL always maps to the empty sentinel, the value reconcile reads as "no such
79/// artifact this run".
80pub fn art_url_hash(url: &str) -> String {
81    if url.is_empty() {
82        String::new()
83    } else {
84        digest(url.as_bytes())
85    }
86}
87
88/// A sentinel for the embedded cover art: a digest of the selected art URL, or
89/// the empty string when the clip carries no art. A mismatch against the
90/// manifest means the file on disk holds stale art even if its tags are current.
91pub fn art_hash(clip: &Clip) -> String {
92    art_url_hash(clip.selected_image_url().unwrap_or(""))
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::lineage::ResolveStatus;
99
100    fn sample() -> Clip {
101        Clip {
102            title: "Electric Storm".to_owned(),
103            tags: "ambient, cinematic".to_owned(),
104            image_large_url: "https://cdn1.suno.ai/image_large_abc.jpeg".to_owned(),
105            image_url: "https://cdn1.suno.ai/image_abc.jpeg".to_owned(),
106            video_cover_url: String::new(),
107            root_ancestor_id: "root-1".to_owned(),
108            lineage_status: "continuation".to_owned(),
109            album_title: "Weather Series".to_owned(),
110            prompt: "an orchestral storm".to_owned(),
111            gpt_description_prompt: "stormy".to_owned(),
112            handle: "alice".to_owned(),
113            display_name: "Alice".to_owned(),
114            ..Default::default()
115        }
116    }
117
118    /// The resolved lineage embedded alongside [`sample`]: an extension of a
119    /// parent under the "Weather Series" root.
120    fn sample_lineage() -> LineageContext {
121        LineageContext {
122            root_id: "root-1".to_owned(),
123            root_title: "Weather Series".to_owned(),
124            parent_id: "parent-1".to_owned(),
125            edge_type: Some(EdgeType::Extend),
126            status: ResolveStatus::Resolved,
127        }
128    }
129
130    #[test]
131    fn meta_hash_is_stable() {
132        // Golden value: a change here means the sentinel encoding changed and
133        // every existing manifest would see a spurious retag. Change with care.
134        let h = meta_hash(&sample(), &sample_lineage());
135        assert_eq!(h, "45ea84e9f71e604f");
136        assert_eq!(h.len(), 16);
137        assert_eq!(h, meta_hash(&sample(), &sample_lineage()));
138    }
139
140    #[test]
141    fn art_hash_is_stable_and_empty_without_art() {
142        let h = art_hash(&sample());
143        assert_eq!(h.len(), 16);
144        assert_eq!(h, art_hash(&sample()));
145
146        let mut bare = sample();
147        bare.image_large_url = String::new();
148        bare.image_url = String::new();
149        bare.video_cover_url = String::new();
150        assert_eq!(art_hash(&bare), "");
151    }
152
153    #[test]
154    fn art_url_hash_is_stable_and_empty_for_empty_url() {
155        assert_eq!(art_url_hash(""), "");
156        let h = art_url_hash("https://cdn1.suno.ai/video_cover.mp4");
157        assert_eq!(h.len(), 16);
158        assert_eq!(h, art_url_hash("https://cdn1.suno.ai/video_cover.mp4"));
159        assert_ne!(h, art_url_hash("https://cdn1.suno.ai/other.mp4"));
160        // art_hash routes the selected image URL through the same helper.
161        assert_eq!(
162            art_hash(&sample()),
163            art_url_hash(sample().selected_image_url().unwrap())
164        );
165    }
166
167    #[test]
168    fn meta_hash_ignores_path_only_fields() {
169        let lineage = sample_lineage();
170        let mut other = sample();
171        other.display_name = "Someone Else".to_owned();
172        assert_eq!(meta_hash(&sample(), &lineage), meta_hash(&other, &lineage));
173    }
174
175    #[test]
176    fn meta_hash_changes_when_a_content_field_changes() {
177        let lineage = sample_lineage();
178        let base = meta_hash(&sample(), &lineage);
179        // Clip-side content fields.
180        for mutate in [
181            |c: &mut Clip| c.title = "Different".to_owned(),
182            |c: &mut Clip| c.tags = "lofi".to_owned(),
183            |c: &mut Clip| c.image_large_url = "https://cdn1.suno.ai/new.jpeg".to_owned(),
184            |c: &mut Clip| c.handle = "bob".to_owned(),
185        ] {
186            let mut clip = sample();
187            mutate(&mut clip);
188            assert_ne!(meta_hash(&clip, &lineage), base);
189        }
190        // Resolved-lineage values that get embedded must also move the hash.
191        for mutate in [
192            |l: &mut LineageContext| l.parent_id = "other-parent".to_owned(),
193            |l: &mut LineageContext| l.root_id = "other-root".to_owned(),
194            |l: &mut LineageContext| l.root_title = "Other Album".to_owned(),
195            |l: &mut LineageContext| l.edge_type = Some(EdgeType::Cover),
196        ] {
197            let mut lin = sample_lineage();
198            mutate(&mut lin);
199            assert_ne!(meta_hash(&sample(), &lin), base);
200        }
201    }
202
203    #[test]
204    fn art_hash_tracks_the_selected_url_in_preference_order() {
205        let mut clip = sample();
206        let large = art_hash(&clip);
207        clip.image_large_url = String::new();
208        let standard = art_hash(&clip);
209        assert_ne!(large, standard);
210        clip.image_url = String::new();
211        clip.video_cover_url = "https://cdn1.suno.ai/video_cover.jpeg".to_owned();
212        let video = art_hash(&clip);
213        assert_ne!(standard, video);
214    }
215
216    #[test]
217    fn content_hash_is_stable_and_tracks_any_change() {
218        let text = "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:60,One\nA/One.flac\n";
219        let h = content_hash(text);
220        assert_eq!(h.len(), 16);
221        assert_eq!(h, content_hash(text), "same text hashes the same");
222        // A different name, order, path, title, or duration changes the digest.
223        assert_ne!(
224            h,
225            content_hash("#EXTM3U\n#PLAYLIST:Other\n#EXTINF:60,One\nA/One.flac\n")
226        );
227        assert_ne!(
228            h,
229            content_hash("#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:61,One\nA/One.flac\n")
230        );
231    }
232}