use std::collections::HashMap;
use futures_util::stream::{self, StreamExt};
use suno_core::{AlignedLyrics, Manifest, SunoClient};
use crate::cli::task_output::eprint_t;
use crate::cli::wallclock;
use crate::clock::TokioClock;
use crate::http::ReqwestHttp;
const SYNCED_LYRICS_FETCH_WARNING: &str = "could not fetch synced lyrics for a clip; its synced lyrics are skipped this run and retried next run";
pub(crate) async fn resolve_synced_lyrics(
desired: &mut [suno_core::Desired],
manifest: &Manifest,
client: &SunoClient<TokioClock>,
http: &ReqwestHttp,
enabled: bool,
verbosity: i8,
concurrency: u32,
) -> (HashMap<String, AlignedLyrics>, Vec<suno_core::PendingCheck>) {
let mut synced: HashMap<String, AlignedLyrics> = HashMap::new();
let targets =
suno_core::synced_lyrics_targets(desired, manifest, wallclock::now_secs(), enabled);
let fetched = stream::iter(targets.iter())
.map(|id| async move { (id.clone(), client.aligned_lyrics(http, id).await) })
.buffered(concurrency.max(1) as usize)
.collect::<Vec<_>>()
.await;
for (id, result) in fetched {
match result {
Ok(aligned) => {
synced.insert(id, aligned);
}
Err(_) => {
if verbosity >= -1 {
eprint_t!("warning: {SYNCED_LYRICS_FETCH_WARNING}");
}
}
}
}
let pending = suno_core::apply_synced_lrc(desired, manifest, &synced);
(synced, pending)
}
pub(crate) fn record_synced_lyrics_checks(
manifest: &mut Manifest,
pending: &[suno_core::PendingCheck],
) {
let now = wallclock::now_secs();
for check in pending {
let durable = if check.empty {
true
} else if let Some(entry) = manifest.get(&check.clip_id) {
!check.written_slots.is_empty()
&& check.written_slots.iter().all(|(kind, hash)| {
let slot = match kind {
suno_core::ArtifactKind::Lrc => entry.lrc.as_ref(),
suno_core::ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
_ => None,
};
slot.map(|slot| &slot.hash) == Some(hash)
})
} else {
false
};
if !durable {
continue;
}
if let Some(entry) = manifest.entries.get_mut(&check.clip_id) {
entry.synced_lyrics = Some(suno_core::SyncedLyricsCheck {
version: suno_core::SYNCED_LRC_VERSION,
checked_unix: now,
empty: check.empty,
timed: check.timed,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use suno_core::{ArtifactKind, ArtifactState, ManifestEntry, PendingCheck};
#[test]
fn synced_lyrics_fetch_warning_never_leaks_a_clip_id_or_url() {
let msg = SYNCED_LYRICS_FETCH_WARNING;
assert!(!msg.contains("/api/gen/"));
assert!(!msg.contains("aligned_lyrics"));
assert!(!msg.contains('{'), "no interpolation placeholder");
assert!(!msg.contains("http"));
}
#[test]
fn lyrics_only_marker_persists_on_lyrics_txt_slot() {
let mut manifest = Manifest::new();
let entry = ManifestEntry {
lyrics_txt: Some(ArtifactState {
path: "a.lyrics.txt".to_string(),
hash: "body-hash".to_string(),
}),
..Default::default()
};
manifest.insert("a", entry);
let pending = vec![PendingCheck {
clip_id: "a".to_string(),
empty: false,
timed: true,
written_slots: vec![(ArtifactKind::LyricsTxt, "body-hash".to_string())],
}];
record_synced_lyrics_checks(&mut manifest, &pending);
let check = manifest.get("a").unwrap().synced_lyrics.clone();
assert!(
check.is_some(),
"the marker persists off the `.lyrics.txt` slot"
);
assert!(check.unwrap().timed);
}
#[test]
fn lyrics_only_marker_skipped_when_lyrics_txt_slot_missing_the_body() {
let mut manifest = Manifest::new();
let entry = ManifestEntry {
lyrics_txt: Some(ArtifactState {
path: "a.lyrics.txt".to_string(),
hash: "OLD".to_string(),
}),
..Default::default()
};
manifest.insert("a", entry);
let pending = vec![PendingCheck {
clip_id: "a".to_string(),
empty: false,
timed: true,
written_slots: vec![(ArtifactKind::LyricsTxt, "body-hash".to_string())],
}];
record_synced_lyrics_checks(&mut manifest, &pending);
assert!(
manifest.get("a").unwrap().synced_lyrics.is_none(),
"no marker until the slot reflects the body -> retried"
);
}
#[test]
fn lyrics_txt_write_failure_is_retried_when_lrc_succeeded() {
let mut manifest = Manifest::new();
let entry = ManifestEntry {
lrc: Some(ArtifactState {
path: "a.lrc".to_string(),
hash: "lrc-hash".to_string(),
}),
..Default::default()
};
manifest.insert("a", entry);
let pending = vec![PendingCheck {
clip_id: "a".to_string(),
empty: false,
timed: true,
written_slots: vec![
(ArtifactKind::Lrc, "lrc-hash".to_string()),
(ArtifactKind::LyricsTxt, "txt-hash".to_string()),
],
}];
record_synced_lyrics_checks(&mut manifest, &pending);
assert!(
manifest.get("a").unwrap().synced_lyrics.is_none(),
"a partial write (only the `.lrc` landed) records no marker -> retried"
);
}
#[test]
fn both_slots_landed_records_the_marker() {
let mut manifest = Manifest::new();
let entry = ManifestEntry {
lrc: Some(ArtifactState {
path: "a.lrc".to_string(),
hash: "lrc-hash".to_string(),
}),
lyrics_txt: Some(ArtifactState {
path: "a.lyrics.txt".to_string(),
hash: "txt-hash".to_string(),
}),
..Default::default()
};
manifest.insert("a", entry);
let pending = vec![PendingCheck {
clip_id: "a".to_string(),
empty: false,
timed: true,
written_slots: vec![
(ArtifactKind::Lrc, "lrc-hash".to_string()),
(ArtifactKind::LyricsTxt, "txt-hash".to_string()),
],
}];
record_synced_lyrics_checks(&mut manifest, &pending);
assert!(
manifest.get("a").unwrap().synced_lyrics.is_some(),
"both slots landed -> resolved (converges)"
);
}
}