use std::collections::{BTreeSet, HashMap};
use crate::hash::{
SYNCED_LRC_VERSION, content_hash, lyrics_txt_source_hash, synced_lrc_source_hash,
};
use crate::lyrics::{AlignedLyrics, render_clip_lrc, render_clip_lyrics, render_synced_lrc};
use crate::manifest::{Manifest, ManifestEntry};
use crate::model::Clip;
use crate::reconcile::Desired;
use crate::vocab::ArtifactKind;
pub const SYNCED_LRC_RECHECK_SECS: u64 = 14 * 24 * 60 * 60;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingCheck {
pub clip_id: String,
pub empty: bool,
pub timed: bool,
pub written_slots: Vec<(ArtifactKind, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SlotOutcome {
Inert,
Instrumental,
Wrote(String),
}
fn plain_lyrics(clip: &Clip, aligned: &AlignedLyrics) -> Option<String> {
if let Some(text) = render_clip_lyrics(clip) {
return Some(text);
}
let plain = aligned.plain_text();
let plain = plain.trim_end();
if plain.is_empty() {
return None;
}
Some(format!("{plain}\n"))
}
fn needs_fetch(
entry: Option<&ManifestEntry>,
desired_path: &str,
desired_kind: ArtifactKind,
now_unix: u64,
) -> bool {
let Some(entry) = entry else {
return true; };
if let Some(slot) = entry.lrc.as_ref()
&& slot.hash != entry.embedded_lyrics_hash
{
return true;
}
let Some(check) = entry.synced_lyrics.as_ref() else {
return true; };
if check.version != SYNCED_LRC_VERSION {
return true; }
if check.empty {
return now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS;
}
let slot = match desired_kind {
ArtifactKind::Lrc => entry.lrc.as_ref(),
ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
_ => None,
};
match slot {
None => true,
Some(s) if s.path != desired_path => true,
Some(_) if !check.timed => {
now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS
}
Some(_) => false,
}
}
pub fn synced_lyrics_targets(
desired: &[Desired],
manifest: &Manifest,
now_unix: u64,
enabled: bool,
) -> BTreeSet<String> {
if !enabled {
return BTreeSet::new();
}
let mut out = BTreeSet::new();
for d in desired {
let mut lyric_slots = d
.artifacts
.iter()
.filter(|a| matches!(a.kind, ArtifactKind::Lrc | ArtifactKind::LyricsTxt))
.peekable();
if lyric_slots.peek().is_none() {
continue;
}
let entry = manifest.get(&d.clip.id);
let reformat_reembed = entry.is_some_and(|e| e.format != d.format && e.lrc.is_some());
let any_slot_needs =
lyric_slots.any(|a| needs_fetch(entry, a.path.as_str(), a.kind, now_unix));
if reformat_reembed || any_slot_needs {
out.insert(d.clip.id.clone());
}
}
out
}
pub fn apply_synced_lrc(
desired: &mut [Desired],
manifest: &Manifest,
successes: &HashMap<String, AlignedLyrics>,
) -> Vec<PendingCheck> {
let mut pending = Vec::new();
for d in desired.iter_mut() {
d.embedded_lyrics_hash = manifest
.get(&d.clip.id)
.map(|e| e.embedded_lyrics_hash.clone())
.unwrap_or_default();
let aligned = successes.get(&d.clip.id);
let lrc = apply_lrc_slot(d, manifest, aligned);
let lyrics_txt = apply_lyrics_txt_slot(d, manifest, aligned);
if let Some(check) = build_pending_check(&d.clip.id, aligned, &lrc, &lyrics_txt) {
pending.push(check);
}
}
pending
}
fn build_pending_check(
clip_id: &str,
aligned: Option<&AlignedLyrics>,
lrc: &SlotOutcome,
lyrics_txt: &SlotOutcome,
) -> Option<PendingCheck> {
let aligned = aligned?;
let desired_lyric =
!matches!(lrc, SlotOutcome::Inert) || !matches!(lyrics_txt, SlotOutcome::Inert);
if !desired_lyric {
return None;
}
let mut written_slots = Vec::new();
if let SlotOutcome::Wrote(hash) = lrc {
written_slots.push((ArtifactKind::Lrc, hash.clone()));
}
if let SlotOutcome::Wrote(hash) = lyrics_txt {
written_slots.push((ArtifactKind::LyricsTxt, hash.clone()));
}
Some(PendingCheck {
clip_id: clip_id.to_string(),
empty: written_slots.is_empty(),
timed: !aligned.is_empty(),
written_slots,
})
}
fn apply_lrc_slot(
d: &mut Desired,
manifest: &Manifest,
aligned: Option<&AlignedLyrics>,
) -> SlotOutcome {
let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
return SlotOutcome::Inert;
};
let clip_id = d.clip.id.clone();
let slot_hash = manifest
.get(&clip_id)
.and_then(|e| e.lrc.as_ref())
.map(|slot| slot.hash.clone());
let Some(aligned) = aligned else {
match slot_hash {
Some(hash) => {
let artifact = &mut d.artifacts[idx];
artifact.hash = hash;
artifact.content = None;
}
None => {
d.artifacts.remove(idx);
}
}
return SlotOutcome::Inert;
};
let timed = !aligned.is_empty();
let body = if timed {
render_synced_lrc(&d.clip, &d.lineage, aligned)
} else {
render_clip_lrc(&d.clip, &d.lineage)
};
match body {
Some(text) => {
let hash = content_hash(&text);
d.embedded_lyrics_hash = hash.clone();
let artifact = &mut d.artifacts[idx];
artifact.hash = hash.clone();
artifact.content = Some(text);
SlotOutcome::Wrote(hash)
}
None => {
d.embedded_lyrics_hash = String::new();
d.artifacts.remove(idx);
SlotOutcome::Instrumental
}
}
}
fn apply_lyrics_txt_slot(
d: &mut Desired,
manifest: &Manifest,
aligned: Option<&AlignedLyrics>,
) -> SlotOutcome {
let Some(idx) = d
.artifacts
.iter()
.position(|a| a.kind == ArtifactKind::LyricsTxt)
else {
return SlotOutcome::Inert;
};
let slot_hash = manifest
.get(&d.clip.id)
.and_then(|e| e.lyrics_txt.as_ref())
.map(|slot| slot.hash.clone());
let Some(aligned) = aligned else {
match slot_hash {
Some(hash) => {
let artifact = &mut d.artifacts[idx];
artifact.hash = hash;
artifact.content = None;
}
None => {
d.artifacts.remove(idx);
}
}
return SlotOutcome::Inert;
};
match plain_lyrics(&d.clip, aligned) {
Some(text) => {
let hash = content_hash(&text);
let artifact = &mut d.artifacts[idx];
artifact.hash = hash.clone();
artifact.content = Some(text);
SlotOutcome::Wrote(hash)
}
None => {
d.artifacts.remove(idx);
SlotOutcome::Instrumental
}
}
}
pub fn preview_synced_lrc(
desired: &mut [Desired],
manifest: &Manifest,
now_unix: u64,
enabled: bool,
) {
for d in desired.iter_mut() {
let entry = manifest.get(&d.clip.id);
d.embedded_lyrics_hash = entry
.map(|e| e.embedded_lyrics_hash.clone())
.unwrap_or_default();
if let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) {
let path = d.artifacts[idx].path.clone();
if enabled && needs_fetch(entry, &path, ArtifactKind::Lrc, now_unix) {
d.embedded_lyrics_hash = entry
.and_then(|e| e.lrc.as_ref())
.map(|s| s.hash.clone())
.unwrap_or_default();
d.artifacts[idx].hash = synced_lrc_source_hash(&d.clip.id);
} else {
match entry.and_then(|e| e.lrc.as_ref()) {
Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
None => {
d.artifacts.remove(idx);
}
}
}
}
if let Some(idx) = d
.artifacts
.iter()
.position(|a| a.kind == ArtifactKind::LyricsTxt)
{
let path = d.artifacts[idx].path.clone();
if enabled && needs_fetch(entry, &path, ArtifactKind::LyricsTxt, now_unix) {
d.artifacts[idx].hash = lyrics_txt_source_hash(&d.clip.id);
} else {
match entry.and_then(|e| e.lyrics_txt.as_ref()) {
Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
None => {
d.artifacts.remove(idx);
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lineage::LineageContext;
use crate::lyrics::{AlignedLine, AlignedLineWord};
use crate::manifest::{ArtifactState, SyncedLyricsCheck};
use crate::model::Clip;
use crate::reconcile::DesiredArtifact;
use crate::vocab::AudioFormat;
fn clip(id: &str, lyrics: &str) -> Clip {
Clip {
id: id.to_string(),
title: "Song".to_string(),
lyrics: lyrics.to_string(),
prompt: "a prompt".to_string(),
..Default::default()
}
}
fn lrc_artifact(clip_id: &str) -> DesiredArtifact {
DesiredArtifact {
kind: ArtifactKind::Lrc,
path: format!("{clip_id}.lrc"),
source_url: String::new(),
hash: synced_lrc_source_hash(clip_id),
content: None,
}
}
fn desired(id: &str, lyrics: &str) -> Desired {
let c = clip(id, lyrics);
Desired {
lineage: LineageContext::own_root(&c),
path: format!("{id}.flac"),
format: AudioFormat::Flac,
meta_hash: "m".to_string(),
art_hash: "a".to_string(),
embedded_lyrics_hash: String::new(),
modes: vec![crate::vocab::SourceMode::Mirror],
trashed: false,
private: false,
artifacts: vec![lrc_artifact(id)],
clip: c,
stems: None,
}
}
fn lyrics_txt_artifact(clip_id: &str) -> DesiredArtifact {
DesiredArtifact {
kind: ArtifactKind::LyricsTxt,
path: format!("{clip_id}.lyrics.txt"),
source_url: String::new(),
hash: lyrics_txt_source_hash(clip_id),
content: None,
}
}
fn desired_lyrics_only(id: &str, lyrics: &str) -> Desired {
let mut d = desired(id, lyrics);
d.artifacts = vec![lyrics_txt_artifact(id)];
d
}
fn desired_both(id: &str, lyrics: &str) -> Desired {
let mut d = desired(id, lyrics);
d.artifacts = vec![lrc_artifact(id), lyrics_txt_artifact(id)];
d
}
fn one_line_alignment() -> AlignedLyrics {
AlignedLyrics {
lines: vec![AlignedLine {
text: "hi there".to_owned(),
start_s: 0.5,
end_s: 1.2,
section: "Verse 1".to_owned(),
words: vec![
AlignedLineWord {
text: "hi".to_owned(),
start_s: 0.5,
end_s: 0.8,
},
AlignedLineWord {
text: "there".to_owned(),
start_s: 0.9,
end_s: 1.2,
},
],
}],
..Default::default()
}
}
fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
let embedded_lyrics_hash = lrc.as_ref().map(|s| s.hash.clone()).unwrap_or_default();
ManifestEntry {
path: "song.flac".to_string(),
format: AudioFormat::Flac,
lrc,
embedded_lyrics_hash,
synced_lyrics: check,
..Default::default()
}
}
#[test]
fn targets_empty_when_feature_off() {
let d = vec![desired("a", "")];
let manifest = Manifest::new();
assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
}
#[test]
fn targets_new_clip_but_not_a_recently_resolved_one() {
let d = vec![desired("new", ""), desired("done", "")];
let mut manifest = Manifest::new();
manifest.insert(
"done",
entry(
Some(ArtifactState {
path: "done.lrc".to_string(),
hash: "h".to_string(),
}),
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 1_000,
empty: false,
timed: true,
}),
),
);
let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
assert!(targets.contains("new"));
assert!(!targets.contains("done"));
}
#[test]
fn instrumental_is_rechecked_only_after_the_window() {
let d = vec![desired("instr", "")];
let mut manifest = Manifest::new();
manifest.insert(
"instr",
entry(
None,
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 1_000,
empty: true,
timed: false,
}),
),
);
let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
}
#[test]
fn untimed_fallback_is_rechecked_after_the_window() {
let d = vec![desired("a", "some lyrics")];
let mut manifest = Manifest::new();
manifest.insert(
"a",
entry(
Some(ArtifactState {
path: "a.lrc".to_string(),
hash: "untimed-hash".to_string(),
}),
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 1_000,
empty: false,
timed: false,
}),
),
);
let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("a"));
}
#[test]
fn timed_clip_is_not_rechecked_without_rename() {
let d = vec![desired("a", "")];
let mut manifest = Manifest::new();
manifest.insert(
"a",
entry(
Some(ArtifactState {
path: "a.lrc".to_string(),
hash: "h".to_string(),
}),
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 0, empty: false,
timed: true,
}),
),
);
let very_late = 2 * SYNCED_LRC_RECHECK_SECS;
assert!(synced_lyrics_targets(&d, &manifest, very_late, true).is_empty());
}
#[test]
fn version_bump_refetches_everything() {
let d = vec![desired("done", "")];
let mut manifest = Manifest::new();
manifest.insert(
"done",
entry(
Some(ArtifactState {
path: "done.lrc".to_string(),
hash: "h".to_string(),
}),
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION + 1, checked_unix: 1_000,
empty: false,
timed: true,
}),
),
);
assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
}
#[test]
fn rename_refetches_a_written_clip() {
let mut d = vec![desired("a", "")];
d[0].artifacts[0].path = "new/a.lrc".to_string();
let mut manifest = Manifest::new();
manifest.insert(
"a",
entry(
Some(ArtifactState {
path: "old/a.lrc".to_string(),
hash: "h".to_string(),
}),
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 1_000,
empty: false,
timed: true,
}),
),
);
assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
}
#[test]
fn apply_sets_timed_body_and_content_hash() {
let mut d = vec![desired("a", "")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
let art = &d[0].artifacts[0];
let body = art.content.as_deref().unwrap();
assert!(body.contains("[00:00.50]hi there"));
assert_eq!(art.hash, content_hash(body));
assert_eq!(
pending,
vec![PendingCheck {
clip_id: "a".to_string(),
empty: false,
timed: true,
written_slots: vec![(ArtifactKind::Lrc, content_hash(body))],
}]
);
}
#[test]
fn apply_untimed_fallback_marks_not_timed() {
let mut d = vec![desired("a", "some lyrics")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), AlignedLyrics::default());
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
let art = &d[0].artifacts[0];
assert!(art.content.is_some(), "untimed body written");
let check = &pending[0];
assert!(!check.empty, "clip has lyrics, not an instrumental");
assert!(!check.timed, "alignment was empty -> untimed fallback");
}
#[test]
fn apply_drops_instrumental_and_marks_empty() {
let mut d = vec![desired("instr", "")];
let mut successes = HashMap::new();
successes.insert("instr".to_string(), AlignedLyrics::default());
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
assert_eq!(
pending,
vec![PendingCheck {
clip_id: "instr".to_string(),
empty: true,
timed: false,
written_slots: vec![],
}]
);
}
#[test]
fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
let mut d = vec![desired("a", "")];
let mut manifest = Manifest::new();
manifest.insert(
"a",
entry(
Some(ArtifactState {
path: "a.lrc".to_string(),
hash: "timed-hash".to_string(),
}),
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 1_000,
empty: false,
timed: true,
}),
),
);
let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
let art = &d[0].artifacts[0];
assert_eq!(art.hash, "timed-hash");
assert_eq!(art.content, None);
assert!(
pending.is_empty(),
"no check recorded on failure -> retried"
);
}
#[test]
fn apply_drops_write_on_failure_when_nothing_on_disk() {
let mut d = vec![desired("a", "")];
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
assert!(pending.is_empty());
}
#[test]
fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
let mut d = vec![desired("a", "some lyrics")];
let untimed_hash = "untimed".to_string();
let mut manifest = Manifest::new();
manifest.insert(
"a",
entry(
Some(ArtifactState {
path: "a.lrc".to_string(),
hash: untimed_hash.clone(),
}),
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 1_000,
empty: false,
timed: false,
}),
),
);
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
let pending = apply_synced_lrc(&mut d, &manifest, &successes);
let art = &d[0].artifacts[0];
assert!(
art.content
.as_deref()
.unwrap()
.contains("[00:00.50]hi there")
);
assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
assert!(pending[0].timed, "upgraded to timed");
}
#[test]
fn preview_shows_write_for_targets_and_skips_resolved() {
let mut d = vec![desired("new", ""), desired("done", "")];
let mut manifest = Manifest::new();
manifest.insert(
"done",
entry(
Some(ArtifactState {
path: "done.lrc".to_string(),
hash: "slot-hash".to_string(),
}),
Some(SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 1_000,
empty: false,
timed: true,
}),
),
);
preview_synced_lrc(&mut d, &manifest, 2_000, true);
assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
assert_eq!(d[1].artifacts[0].hash, "slot-hash");
}
fn timed_check() -> SyncedLyricsCheck {
SyncedLyricsCheck {
version: SYNCED_LRC_VERSION,
checked_unix: 1_000,
empty: false,
timed: true,
}
}
fn slot(path: &str, hash: &str) -> Option<ArtifactState> {
Some(ArtifactState {
path: path.to_string(),
hash: hash.to_string(),
})
}
#[test]
fn needs_fetch_backfills_when_embed_missing() {
let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
e.embedded_lyrics_hash = String::new();
assert!(needs_fetch(Some(&e), "a.lrc", ArtifactKind::Lrc, 2_000));
}
#[test]
fn needs_fetch_skips_when_embed_matches_lrc() {
let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
e.embedded_lyrics_hash = "H".to_string();
assert!(!needs_fetch(Some(&e), "a.lrc", ArtifactKind::Lrc, 2_000));
}
#[test]
fn needs_fetch_no_backfill_without_lrc_slot() {
let mut e = entry(
None,
Some(SyncedLyricsCheck {
empty: true,
timed: false,
..timed_check()
}),
);
e.embedded_lyrics_hash = String::new();
let within_window = 1_000 + SYNCED_LRC_RECHECK_SECS;
assert!(!needs_fetch(
Some(&e),
"a.lrc",
ArtifactKind::Lrc,
within_window
));
}
#[test]
fn apply_sets_embedded_lyrics_hash_from_body() {
let mut d = vec![desired("a", "")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
apply_synced_lrc(&mut d, &Manifest::new(), &successes);
let art = &d[0].artifacts[0];
let body = art.content.as_deref().unwrap();
assert_eq!(d[0].embedded_lyrics_hash, content_hash(body));
assert_eq!(d[0].embedded_lyrics_hash, art.hash);
}
#[test]
fn apply_clears_embedded_lyrics_hash_for_instrumental() {
let mut d = vec![desired("instr", "")];
let mut manifest = Manifest::new();
let mut e = entry(slot("instr.lrc", "H"), Some(timed_check()));
e.embedded_lyrics_hash = "H".to_string();
manifest.insert("instr", e);
let mut successes = HashMap::new();
successes.insert("instr".to_string(), AlignedLyrics::default());
apply_synced_lrc(&mut d, &manifest, &successes);
assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
assert_eq!(d[0].embedded_lyrics_hash, "");
}
#[test]
fn apply_carries_forward_embedded_lyrics_hash_when_not_fetched() {
let mut d = vec![desired("a", "")];
let mut manifest = Manifest::new();
let mut e = entry(slot("a.lrc", "slot"), Some(timed_check()));
e.embedded_lyrics_hash = "embed".to_string();
manifest.insert("a", e);
apply_synced_lrc(&mut d, &manifest, &HashMap::new());
assert_eq!(
d[0].embedded_lyrics_hash, "embed",
"carry-forward, not the slot hash"
);
assert_eq!(d[0].artifacts[0].hash, "slot");
}
#[test]
fn apply_carries_forward_for_clip_without_lrc_artifact() {
let mut d = vec![desired("a", "")];
d[0].artifacts.clear();
let mut manifest = Manifest::new();
let mut e = entry(None, None);
e.embedded_lyrics_hash = "H".to_string();
manifest.insert("a", e);
apply_synced_lrc(&mut d, &manifest, &HashMap::new());
assert_eq!(d[0].embedded_lyrics_hash, "H");
}
#[test]
fn preview_marks_backfill_target_as_pending() {
let mut d = vec![desired("stale", ""), desired("done", "")];
let mut manifest = Manifest::new();
let mut stale = entry(slot("stale.lrc", "H"), Some(timed_check()));
stale.embedded_lyrics_hash = String::new();
manifest.insert("stale", stale);
let mut done = entry(slot("done.lrc", "D"), Some(timed_check()));
done.embedded_lyrics_hash = "D".to_string();
manifest.insert("done", done);
preview_synced_lrc(&mut d, &manifest, 2_000, true);
assert_eq!(
d[0].embedded_lyrics_hash, "H",
"target previews the back-fill"
);
assert_ne!(
d[0].embedded_lyrics_hash, "",
"differs from the manifest embed -> drift reported"
);
assert_eq!(
d[1].embedded_lyrics_hash, "D",
"resolved clip carries forward"
);
}
#[test]
fn reformat_makes_migrated_clip_a_target() {
let mut manifest = Manifest::new();
let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
e.embedded_lyrics_hash = "H".to_string(); manifest.insert("a", e);
let mut reformat = vec![desired("a", "")];
reformat[0].format = AudioFormat::Mp3;
assert!(
synced_lyrics_targets(&reformat, &manifest, 2_000, true).contains("a"),
"a format change re-embeds a migrated clip"
);
let stable = vec![desired("a", "")]; assert!(
synced_lyrics_targets(&stable, &manifest, 2_000, true).is_empty(),
"no reformat, no back-fill -> no fetch"
);
let mut no_lrc = Manifest::new();
no_lrc.insert(
"a",
entry(
None,
Some(SyncedLyricsCheck {
empty: true,
timed: false,
..timed_check()
}),
),
);
let mut reformat_no_lrc = vec![desired("a", "")];
reformat_no_lrc[0].format = AudioFormat::Mp3;
assert!(
synced_lyrics_targets(&reformat_no_lrc, &no_lrc, 2_000, true).is_empty(),
"no `.lrc` to re-embed -> not a target despite the reformat"
);
}
#[test]
fn apply_fills_lyrics_txt_from_aligned_when_clip_lyrics_empty() {
let mut d = vec![desired_lyrics_only("a", "")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
let art = d[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::LyricsTxt)
.expect("the `.lyrics.txt` survives a lyric fetch");
assert_eq!(art.content.as_deref(), Some("hi there\n"));
assert_eq!(art.hash, content_hash("hi there\n"));
assert_eq!(
pending,
vec![PendingCheck {
clip_id: "a".to_string(),
empty: false,
timed: true,
written_slots: vec![(ArtifactKind::LyricsTxt, content_hash("hi there\n"))],
}]
);
}
#[test]
fn apply_prefers_clip_lyrics_over_aligned_for_lyrics_txt() {
let mut d = vec![desired_lyrics_only("a", "my own words")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment()); apply_synced_lrc(&mut d, &Manifest::new(), &successes);
let art = d[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::LyricsTxt)
.unwrap();
assert_eq!(art.content.as_deref(), Some("my own words\n"));
}
#[test]
fn apply_drops_lyrics_txt_for_instrumental() {
let mut d = vec![desired_lyrics_only("a", "")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), AlignedLyrics::default());
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
assert!(
d[0].artifacts
.iter()
.all(|a| a.kind != ArtifactKind::LyricsTxt),
"no `.lyrics.txt` written for an instrumental"
);
assert_eq!(
pending,
vec![PendingCheck {
clip_id: "a".to_string(),
empty: true,
timed: false,
written_slots: vec![],
}]
);
}
#[test]
fn apply_keeps_lyrics_txt_on_failed_fetch() {
let mut d = vec![desired_lyrics_only("a", "")];
let mut manifest = Manifest::new();
let mut e = entry(None, Some(timed_check()));
e.lyrics_txt = Some(ArtifactState {
path: "a.lyrics.txt".to_string(),
hash: "stored".to_string(),
});
manifest.insert("a", e);
let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
let art = d[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::LyricsTxt)
.unwrap();
assert_eq!(art.hash, "stored", "reset to the stored slot -> skipped");
assert_eq!(art.content, None);
assert!(pending.is_empty(), "no marker on failure -> retried");
}
#[test]
fn lyrics_txt_body_has_exactly_one_trailing_newline() {
for lyrics in ["from the feed", ""] {
let mut d = vec![desired_lyrics_only("a", lyrics)];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
apply_synced_lrc(&mut d, &Manifest::new(), &successes);
let body = d[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::LyricsTxt)
.unwrap()
.content
.clone()
.unwrap();
assert!(body.ends_with('\n'), "one trailing newline: {body:?}");
assert!(!body.ends_with("\n\n"), "not two: {body:?}");
}
}
#[test]
fn lyrics_only_clip_is_a_fetch_target() {
let d = vec![desired_lyrics_only("a", "")];
assert!(
synced_lyrics_targets(&d, &Manifest::new(), 1_000, true).contains("a"),
"a fresh lyrics-only clip is fetched"
);
}
#[test]
fn lyrics_only_clip_is_a_stable_fetch_target_after_first_run() {
let d = vec![desired_lyrics_only("a", "")];
assert!(
synced_lyrics_targets(&d, &Manifest::new(), 1_000, true).contains("a"),
"fetched once"
);
let mut manifest = Manifest::new();
let mut e = entry(None, Some(timed_check()));
e.lyrics_txt = Some(ArtifactState {
path: "a.lyrics.txt".to_string(),
hash: "body-hash".to_string(),
});
manifest.insert("a", e);
assert!(
synced_lyrics_targets(&d, &manifest, 2_000, true).is_empty(),
"a resolved lyrics-only clip converges (no forever re-fetch)"
);
let mut renamed = d.clone();
renamed[0].artifacts[0].path = "new/a.lyrics.txt".to_string();
assert!(
synced_lyrics_targets(&renamed, &manifest, 2_000, true).contains("a"),
"a rename re-fetches so the sidecar moves with the audio"
);
}
#[test]
fn lyrics_only_marker_anchors_on_lyrics_txt_slot() {
let mut d = vec![desired_lyrics_only("a", "")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
assert_eq!(
pending[0].written_slots,
vec![(ArtifactKind::LyricsTxt, content_hash("hi there\n"))]
);
}
#[test]
fn both_slots_recorded_in_the_single_pending_check_when_both_desired() {
let mut d = vec![desired_both("a", "")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
assert_eq!(pending.len(), 1, "one marker per clip");
let lrc = &d[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::Lrc)
.expect("the `.lrc` body is resolved");
assert!(lrc.content.is_some(), "the `.lrc` body is resolved");
assert_eq!(
pending[0].written_slots,
vec![
(ArtifactKind::Lrc, lrc.hash.clone()),
(ArtifactKind::LyricsTxt, content_hash("hi there\n")),
],
"both slots recorded, `.lrc` first"
);
assert!(
d[0].artifacts
.iter()
.any(|a| a.kind == ArtifactKind::LyricsTxt && a.content.is_some()),
"the `.lyrics.txt` body is resolved too"
);
}
#[test]
fn preview_marks_lyrics_txt_pending() {
let mut d = vec![
desired_lyrics_only("new", ""),
desired_lyrics_only("done", ""),
];
let mut manifest = Manifest::new();
let mut done = entry(None, Some(timed_check()));
done.lyrics_txt = Some(ArtifactState {
path: "done.lyrics.txt".to_string(),
hash: "slot-hash".to_string(),
});
manifest.insert("done", done);
preview_synced_lrc(&mut d, &manifest, 2_000, true);
let new_art = d[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::LyricsTxt)
.unwrap();
let done_art = d[1]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::LyricsTxt)
.unwrap();
assert_eq!(new_art.hash, lyrics_txt_source_hash("new"));
assert_eq!(done_art.hash, "slot-hash");
}
#[test]
fn both_sidecars_lrc_already_resolved_backfills_lyrics_txt() {
let mut manifest = Manifest::new();
let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
e.embedded_lyrics_hash = "H".to_string(); manifest.insert("a", e);
let d = vec![desired_both("a", "")];
assert!(
synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"),
"an unresolved `.lyrics.txt` re-targets even when the `.lrc` converged"
);
let mut d2 = d.clone();
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
let pending = apply_synced_lrc(&mut d2, &manifest, &successes);
let txt = d2[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::LyricsTxt)
.expect("the `.lyrics.txt` is back-filled");
assert_eq!(txt.content.as_deref(), Some("hi there\n"));
assert_eq!(pending.len(), 1, "one marker");
assert!(
pending[0]
.written_slots
.iter()
.any(|(k, _)| *k == ArtifactKind::LyricsTxt),
"the marker lists the back-filled `.lyrics.txt` slot"
);
let mut converged = Manifest::new();
let mut e2 = entry(slot("a.lrc", "H"), Some(timed_check()));
e2.embedded_lyrics_hash = "H".to_string();
e2.lyrics_txt = Some(ArtifactState {
path: "a.lyrics.txt".to_string(),
hash: content_hash("hi there\n"),
});
converged.insert("a", e2);
assert!(
synced_lyrics_targets(&d, &converged, 3_000, true).is_empty(),
"both slots resolved -> converged (no re-fetch loop)"
);
}
#[test]
fn inline_lyrics_clip_still_gets_lyrics_txt() {
let d0 = desired_both("a", "hello world");
assert!(
synced_lyrics_targets(std::slice::from_ref(&d0), &Manifest::new(), 1_000, true)
.contains("a"),
"a fresh clip with both sidecars is a fetch target"
);
let mut d = vec![d0];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
apply_synced_lrc(&mut d, &Manifest::new(), &successes);
let txt = d[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::LyricsTxt)
.expect("the `.lyrics.txt` is written");
assert_eq!(
txt.content.as_deref(),
Some("hello world\n"),
"inline `clip.lyrics` win over the aligned text"
);
}
#[test]
fn lyrics_txt_marker_lists_both_slots_so_a_partial_write_is_retried() {
let mut d = vec![desired_both("a", "")];
let mut successes = HashMap::new();
successes.insert("a".to_string(), one_line_alignment());
let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
let lrc_hash = d[0]
.artifacts
.iter()
.find(|a| a.kind == ArtifactKind::Lrc)
.unwrap()
.hash
.clone();
assert_eq!(
pending[0].written_slots,
vec![
(ArtifactKind::Lrc, lrc_hash),
(ArtifactKind::LyricsTxt, content_hash("hi there\n")),
],
"the marker enumerates every written slot for the caller to gate on"
);
}
}