use std::collections::{BTreeSet, HashMap};
use std::path::{Component, Path};
use crate::client::Stem;
use crate::config::{AudioFormat, StemFormat};
use crate::extras::{M3u8Entry, render_clip_details, render_clip_lyrics, render_m3u8};
use crate::hash::{art_hash, art_url_hash, content_hash, meta_hash, synced_lrc_source_hash};
use crate::lineage::LineageContext;
use crate::model::Clip;
use crate::naming::{
CharacterSet, NamingConfig, NamingRequest, render_clip_names, sanitise_name, stem_file_path,
};
use crate::reconcile::{
ArtifactKind, Desired, DesiredArtifact, DesiredStem, PlaylistDesired, SourceMode,
};
pub const LIKED_PLAYLIST_ID: &str = "liked";
#[derive(Debug, Clone, Copy, Default)]
pub struct ArtifactToggles {
pub animated_covers: bool,
pub details: bool,
pub lyrics: bool,
pub lrc: bool,
pub video: bool,
}
pub struct PlaylistInput<'a> {
pub id: &'a str,
pub name: &'a str,
pub members: &'a [Clip],
}
pub fn build_desired(
clips: &[&Clip],
format: AudioFormat,
modes_by_id: &HashMap<String, Vec<SourceMode>>,
contexts: &HashMap<String, LineageContext>,
colliding_albums: &BTreeSet<String>,
toggles: ArtifactToggles,
naming: &NamingConfig,
) -> Vec<Desired> {
let lineages: Vec<LineageContext> = clips
.iter()
.map(|clip| {
contexts
.get(&clip.id)
.cloned()
.unwrap_or_else(|| LineageContext::own_root(clip))
})
.collect();
let names = {
let requests: Vec<NamingRequest<'_>> = clips
.iter()
.zip(&lineages)
.map(|(clip, lineage)| NamingRequest { clip, lineage })
.collect();
render_clip_names(&requests, naming, colliding_albums)
};
clips
.iter()
.zip(names)
.zip(lineages)
.map(|((clip, name), lineage)| {
let base = rel_to_string(&name.relative_path);
let path = format!("{base}.{format}");
let meta_hash = meta_hash(clip, &lineage);
let modes = modes_by_id.get(&clip.id).cloned().unwrap_or_default();
debug_assert!(
!modes.is_empty(),
"clip {} has no modes in the union map",
clip.id
);
let artifacts = clip_artifacts(clip, &base, &lineage, toggles);
Desired {
clip: (*clip).clone(),
lineage,
path,
format,
meta_hash,
art_hash: art_hash(clip),
modes,
trashed: clip.is_trashed,
private: false,
artifacts,
stems: None,
}
})
.collect()
}
pub fn clip_stems(
base: &str,
stems: &[Stem],
stem_format: StemFormat,
character_set: CharacterSet,
) -> Vec<DesiredStem> {
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out = Vec::new();
for (index, stem) in stems.iter().enumerate() {
let base_key = if !stem.id.is_empty() {
stem.id.clone()
} else if !stem.label.is_empty() {
stem.label.clone()
} else {
format!("stem{index}")
};
let mut key = base_key.clone();
let mut suffix = 1;
while !seen.insert(key.clone()) {
key = format!("{base_key}-{suffix}");
suffix += 1;
}
let disambiguator = if stem.id.is_empty() {
key.as_str()
} else {
stem.id.as_str()
};
let format = if stem_format == StemFormat::Wav && stem.id.is_empty() {
StemFormat::Mp3
} else {
stem_format
};
let path = stem_file_path(
base,
&stem.label,
disambiguator,
format.ext(),
character_set,
);
out.push(DesiredStem {
key,
stem_id: stem.id.clone(),
path,
source_url: stem.url.clone(),
format,
hash: art_url_hash(&stem.url),
});
}
out
}
fn clip_artifacts(
clip: &Clip,
base: &str,
lineage: &LineageContext,
toggles: ArtifactToggles,
) -> Vec<DesiredArtifact> {
let mut artifacts = Vec::new();
if let Some(url) = clip.selected_image_url().filter(|u| !u.is_empty()) {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::CoverJpg,
path: format!("{base}.jpg"),
source_url: url.to_owned(),
hash: art_hash(clip),
content: None,
});
}
if toggles.animated_covers && !clip.video_cover_url.is_empty() {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::CoverWebp,
path: format!("{base}.webp"),
source_url: clip.video_cover_url.clone(),
hash: art_url_hash(&clip.video_cover_url),
content: None,
});
}
if toggles.details {
let text = render_clip_details(clip, lineage);
artifacts.push(DesiredArtifact {
kind: ArtifactKind::DetailsTxt,
path: format!("{base}.details.txt"),
source_url: String::new(),
hash: content_hash(&text),
content: Some(text),
});
}
if toggles.lyrics
&& let Some(text) = render_clip_lyrics(clip)
{
artifacts.push(DesiredArtifact {
kind: ArtifactKind::LyricsTxt,
path: format!("{base}.lyrics.txt"),
source_url: String::new(),
hash: content_hash(&text),
content: Some(text),
});
}
if toggles.lrc {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::Lrc,
path: format!("{base}.lrc"),
source_url: String::new(),
hash: synced_lrc_source_hash(&clip.id),
content: None,
});
}
if toggles.video && !clip.video_url.is_empty() {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::VideoMp4,
path: format!("{base}.mp4"),
source_url: clip.video_url.clone(),
hash: art_url_hash(&clip.video_url),
content: None,
});
}
artifacts
}
pub fn build_playlist_desired(
inputs: &[PlaylistInput<'_>],
desired: &[Desired],
) -> Vec<PlaylistDesired> {
let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
inputs
.iter()
.map(|input| {
let entries: Vec<M3u8Entry<'_>> = input
.members
.iter()
.map(|member| match by_id.get(member.id.as_str()) {
Some(d) => M3u8Entry {
title: d.clip.title.as_str(),
duration_secs: d.clip.duration,
relative_path: d.path.as_str(),
},
None => M3u8Entry {
title: member.title.as_str(),
duration_secs: member.duration,
relative_path: "",
},
})
.collect();
let content = render_m3u8(input.name, &entries);
let hash = content_hash(&content);
let path = format!("{}.m3u8", sanitise_name(input.name));
PlaylistDesired {
id: input.id.to_owned(),
name: input.name.to_owned(),
path,
content,
hash,
}
})
.collect()
}
fn rel_to_string(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::collections::HashMap;
use super::*;
use crate::config::AudioFormat;
use crate::hash::{art_hash, art_url_hash, content_hash, synced_lrc_source_hash};
use crate::lineage::LineageContext;
use crate::naming::NamingConfig;
use crate::reconcile::{ArtifactKind, SourceMode};
fn clip(id: &str, title: &str, handle: &str) -> Clip {
Clip {
id: id.to_owned(),
title: title.to_owned(),
handle: handle.to_owned(),
display_name: handle.to_owned(),
..Default::default()
}
}
fn no_contexts() -> HashMap<String, LineageContext> {
HashMap::new()
}
fn no_collisions() -> BTreeSet<String> {
BTreeSet::new()
}
fn modes_for(clips: &[&Clip], mode: SourceMode) -> HashMap<String, Vec<SourceMode>> {
clips.iter().map(|c| (c.id.clone(), vec![mode])).collect()
}
fn art_clip(id: &str) -> Clip {
Clip {
image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
..clip(id, "Song", "alice")
}
}
fn path_of<'a>(desired: &'a [Desired], id: &str) -> &'a str {
desired
.iter()
.find(|d| d.clip.id == id)
.map(|d| d.path.as_str())
.expect("clip in desired set")
}
#[test]
fn build_desired_appends_extension_and_mode() {
let a = clip("id-a", "Song A", "alice");
let clips = [&a];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert_eq!(desired.len(), 1);
assert!(
desired[0].path.ends_with(".flac"),
"path: {}",
desired[0].path
);
assert_eq!(desired[0].format, AudioFormat::Flac);
assert_eq!(desired[0].modes, vec![SourceMode::Mirror]);
assert!(!desired[0].trashed);
assert!(!desired[0].private);
let lineage = LineageContext::own_root(&a);
assert_eq!(desired[0].meta_hash, crate::hash::meta_hash(&a, &lineage));
assert_eq!(desired[0].art_hash, art_hash(&a));
assert_eq!(desired[0].lineage, lineage);
}
#[test]
fn build_desired_carries_the_trashed_flag_from_the_clip() {
let mut gone = clip("id-gone", "Removed", "alice");
gone.is_trashed = true;
let live = clip("id-live", "Kept", "alice");
let clips = [&gone, &live];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert!(desired[0].trashed, "a trashed clip is marked trashed");
assert!(!desired[1].trashed, "a live clip is not");
}
#[test]
fn build_desired_uses_supplied_lineage_context() {
use crate::lineage::ResolveStatus;
let a = clip("child-1", "Remix", "alice");
let clips = [&a];
let lineage = LineageContext {
root_id: "root-1".to_owned(),
root_title: "Original".to_owned(),
root_date: String::new(),
parent_id: "root-1".to_owned(),
edge_type: None,
status: ResolveStatus::Resolved,
};
let contexts: HashMap<String, LineageContext> =
[(a.id.clone(), lineage.clone())].into_iter().collect();
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&contexts,
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert!(
desired[0].path.contains("/Original/"),
"path: {}",
desired[0].path
);
assert_eq!(desired[0].lineage, lineage);
assert_eq!(desired[0].meta_hash, crate::hash::meta_hash(&a, &lineage));
}
#[test]
fn lineage_is_stable_when_a_later_resolution_fails() {
use crate::graph::LineageStore;
use crate::lineage::{Resolution, ResolveStatus, RootInfo};
let root = Clip {
id: "root-break".into(),
title: "Break Through".into(),
clip_type: "gen".into(),
handle: "alice".into(),
display_name: "alice".into(),
..Default::default()
};
let child = Clip {
id: "child-remix".into(),
title: "Remix".into(),
clip_type: "gen".into(),
task: "cover".into(),
cover_clip_id: "root-break".into(),
edited_clip_id: "root-break".into(),
handle: "alice".into(),
display_name: "alice".into(),
..Default::default()
};
let clips = [&root, &child];
let contexts_of = |store: &LineageStore| -> HashMap<String, LineageContext> {
clips
.iter()
.map(|c| (c.id.clone(), store.context_for(c)))
.collect()
};
let mut roots = HashMap::new();
for id in ["root-break", "child-remix"] {
roots.insert(
id.to_owned(),
RootInfo {
root_id: "root-break".into(),
root_title: "Break Through".into(),
status: ResolveStatus::Resolved,
},
);
}
let resolution = Resolution {
roots,
gap_filled: Vec::new(),
bridges: Vec::new(),
};
let mut store = LineageStore::new();
store.update(&[root.clone(), child.clone()], &resolution, "t1");
let cycle1 = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&contexts_of(&store),
&store.colliding_root_titles(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
let child1 = cycle1.iter().find(|d| d.clip.id == "child-remix").unwrap();
assert!(
child1.path.contains("/Break Through/"),
"the remix should folder under its root album, got {}",
child1.path
);
let cycle2 = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&contexts_of(&store),
&store.colliding_root_titles(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
for (a, b) in cycle1.iter().zip(&cycle2) {
assert_eq!(a.path, b.path, "album path drifted for {}", a.clip.id);
assert_eq!(
a.meta_hash, b.meta_hash,
"meta_hash drifted for {}",
a.clip.id
);
}
let own = LineageContext::own_root(&child);
assert_ne!(
crate::hash::meta_hash(&child, &own),
child1.meta_hash,
"own-root fallback must differ from the store-driven hash"
);
}
#[test]
fn build_desired_disambiguates_collisions() {
let a = clip("id-a", "Same", "alice");
let b = clip("id-b", "Same", "alice");
let clips = [&a, &b];
let desired = build_desired(
&clips,
AudioFormat::Mp3,
&modes_for(&clips, SourceMode::Copy),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert_ne!(desired[0].path, desired[1].path);
assert!(desired.iter().all(|d| d.path.ends_with(".mp3")));
assert!(desired.iter().all(|d| d.modes == vec![SourceMode::Copy]));
}
#[test]
fn build_desired_uses_forward_slashes() {
let a = clip("id-a", "Song A", "alice");
let clips = [&a];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert!(!desired[0].path.contains('\\'));
assert!(desired[0].path.contains('/'));
}
#[test]
fn build_desired_emits_cover_jpg_next_to_audio() {
let a = art_clip("id-a");
let clips = [&a];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
let base = desired[0].path.strip_suffix(".flac").unwrap();
assert_eq!(desired[0].artifacts.len(), 1);
let jpg = &desired[0].artifacts[0];
assert_eq!(jpg.kind, ArtifactKind::CoverJpg);
assert_eq!(jpg.path, format!("{base}.jpg"));
assert_eq!(jpg.source_url, a.selected_image_url().unwrap());
assert_eq!(jpg.hash, art_hash(&a));
}
#[test]
fn build_desired_omits_cover_jpg_when_art_is_empty() {
let a = clip("id-a", "Song", "alice");
assert!(a.selected_image_url().is_none());
let clips = [&a];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
animated_covers: true,
..Default::default()
},
&NamingConfig::default(),
);
assert!(desired[0].artifacts.is_empty());
}
#[test]
fn build_desired_emits_cover_webp_only_when_animated_and_video_present() {
let with_video = Clip {
video_cover_url: "https://cdn.suno.ai/id-a/video.mp4".to_owned(),
..art_clip("id-a")
};
let clips = [&with_video];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert_eq!(desired[0].artifacts.len(), 1);
assert_eq!(desired[0].artifacts[0].kind, ArtifactKind::CoverJpg);
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
animated_covers: true,
..Default::default()
},
&NamingConfig::default(),
);
let base = desired[0].path.strip_suffix(".flac").unwrap();
let webp = desired[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::CoverWebp)
.expect("animated cover expected");
assert_eq!(webp.path, format!("{base}.webp"));
assert_eq!(webp.source_url, with_video.video_cover_url);
assert_eq!(webp.hash, art_url_hash(&with_video.video_cover_url));
let no_video = art_clip("id-b");
let clips = [&no_video];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
animated_covers: true,
..Default::default()
},
&NamingConfig::default(),
);
assert!(
desired[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::CoverWebp)
);
}
#[test]
fn build_desired_emits_video_mp4_only_when_enabled_and_video_present() {
let with_video = Clip {
video_url: "https://cdn.suno.ai/id-a/video.mp4".to_owned(),
..art_clip("id-a")
};
let clips = [&with_video];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert!(
desired[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::VideoMp4)
);
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
video: true,
..Default::default()
},
&NamingConfig::default(),
);
let base = desired[0].path.strip_suffix(".flac").unwrap();
let video = desired[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::VideoMp4)
.expect("video expected");
assert_eq!(video.path, format!("{base}.mp4"));
assert_eq!(video.source_url, with_video.video_url);
assert_eq!(video.hash, art_url_hash(&with_video.video_url));
assert!(video.content.is_none());
let no_video = art_clip("id-b");
let clips = [&no_video];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
video: true,
..Default::default()
},
&NamingConfig::default(),
);
assert!(
desired[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::VideoMp4)
);
}
#[test]
fn build_desired_emits_details_sidecar_only_when_enabled() {
use crate::extras::render_clip_details;
use crate::hash::content_hash;
let a = clip("id-a", "Song", "alice");
let clips = [&a];
let off = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert!(
off[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::DetailsTxt)
);
let on = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
details: true,
..Default::default()
},
&NamingConfig::default(),
);
let base = on[0].path.strip_suffix(".flac").unwrap();
let details = on[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::DetailsTxt)
.expect("details sidecar expected");
assert_eq!(details.path, format!("{base}.details.txt"));
assert_eq!(details.source_url, "");
let body = render_clip_details(&a, &LineageContext::own_root(&a));
assert_eq!(details.content.as_deref(), Some(body.as_str()));
assert_eq!(details.hash, content_hash(&body));
}
#[test]
fn build_desired_emits_lyrics_sidecar_only_when_enabled_and_present() {
let with_lyrics = Clip {
lyrics: "la la la".to_owned(),
..clip("id-a", "Song", "alice")
};
let clips = [&with_lyrics];
let off = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert!(
off[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::LyricsTxt)
);
let on = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lyrics: true,
..Default::default()
},
&NamingConfig::default(),
);
let base = on[0].path.strip_suffix(".flac").unwrap();
let lyrics = on[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::LyricsTxt)
.expect("lyrics sidecar expected");
assert_eq!(lyrics.path, format!("{base}.lyrics.txt"));
assert_eq!(lyrics.source_url, "");
assert_eq!(lyrics.content.as_deref(), Some("la la la\n"));
assert_eq!(lyrics.hash, content_hash("la la la\n"));
}
#[test]
fn build_desired_emits_lrc_sidecar_only_when_enabled() {
let with_lyrics = Clip {
lyrics: "la la la".to_owned(),
..clip("id-a", "Song", "alice")
};
let clips = [&with_lyrics];
let off = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert!(
off[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::Lrc)
);
let on = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lrc: true,
..Default::default()
},
&NamingConfig::default(),
);
let base = on[0].path.strip_suffix(".flac").unwrap();
let lrc = on[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::Lrc)
.expect("lrc sidecar expected");
assert_eq!(lrc.path, format!("{base}.lrc"));
assert_eq!(lrc.source_url, "");
assert_eq!(lrc.content, None);
assert_eq!(lrc.hash, synced_lrc_source_hash(&with_lyrics.id));
}
#[test]
fn build_desired_emits_lrc_sidecar_from_prompt_when_feed_omits_lyrics() {
let prompt_only = Clip {
prompt: "the sung words live here".to_owned(),
..clip("id-a", "Song", "alice")
};
assert!(prompt_only.lyrics.is_empty());
let clips = [&prompt_only];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lrc: true,
..Default::default()
},
&NamingConfig::default(),
);
let lrc = desired[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::Lrc)
.expect("lrc sidecar expected");
assert_eq!(lrc.content, None);
assert_eq!(lrc.hash, synced_lrc_source_hash(&prompt_only.id));
}
#[test]
fn build_desired_emits_lrc_sidecar_even_when_feed_has_no_lyrics_or_prompt() {
let bare = clip("id-a", "Song", "alice");
assert!(bare.lyrics.is_empty() && bare.prompt.is_empty());
let clips = [&bare];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lrc: true,
..Default::default()
},
&NamingConfig::default(),
);
let lrc = desired[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::Lrc)
.expect("lrc sidecar expected even with no feed lyrics/prompt");
assert_eq!(lrc.content, None);
assert_eq!(lrc.hash, synced_lrc_source_hash(&bare.id));
}
#[test]
fn build_desired_omits_lyrics_sidecar_when_clip_has_no_lyrics() {
let no_lyrics = clip("id-a", "Song", "alice");
assert!(no_lyrics.lyrics.is_empty());
let clips = [&no_lyrics];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lyrics: true,
..Default::default()
},
&NamingConfig::default(),
);
assert!(
desired[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::LyricsTxt)
);
}
#[test]
fn build_desired_text_sidecars_are_independent() {
let full = Clip {
lyrics: "words".to_owned(),
..art_clip("id-a")
};
let clips = [&full];
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes_for(&clips, SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles {
details: true,
lyrics: true,
..Default::default()
},
&NamingConfig::default(),
);
let base = desired[0].path.strip_suffix(".flac").unwrap();
let kinds: BTreeSet<ArtifactKind> = desired[0].artifacts.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ArtifactKind::CoverJpg));
assert!(kinds.contains(&ArtifactKind::DetailsTxt));
assert!(kinds.contains(&ArtifactKind::LyricsTxt));
let path_of_kind = |k: ArtifactKind| {
desired[0]
.artifacts
.iter()
.find(|a| a.kind == k)
.unwrap()
.path
.clone()
};
assert_eq!(
path_of_kind(ArtifactKind::DetailsTxt),
format!("{base}.details.txt")
);
assert_eq!(
path_of_kind(ArtifactKind::LyricsTxt),
format!("{base}.lyrics.txt")
);
}
#[test]
fn build_desired_one_pass_disambiguates_and_stamps_modes() {
let a = clip("lib-1", "Song", "alice");
let b = clip("pl-1", "Song", "alice");
let clips = [&a, &b];
let mut modes = HashMap::new();
modes.insert("lib-1".to_owned(), vec![SourceMode::Copy]);
modes.insert(
"pl-1".to_owned(),
vec![SourceMode::Mirror, SourceMode::Copy],
);
let desired = build_desired(
&clips,
AudioFormat::Flac,
&modes,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
assert_eq!(desired.len(), 2);
assert_ne!(desired[0].path, desired[1].path);
assert_eq!(desired[1].modes, vec![SourceMode::Mirror, SourceMode::Copy]);
}
#[test]
fn build_desired_respects_custom_naming_config() {
use crate::naming::CharacterSet;
let a = clip("abcdefgh-1234", "Song A", "alice");
let clips = [&a];
let custom = NamingConfig {
template: "{title}/{id8}".to_owned(),
character_set: CharacterSet::Ascii,
..NamingConfig::default()
};
let desired = build_desired(
&clips,
AudioFormat::Flac,
&HashMap::from([("abcdefgh-1234".to_owned(), vec![SourceMode::Mirror])]),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&custom,
);
assert!(
desired[0].path.starts_with("Song A/"),
"path: {}",
desired[0].path
);
assert!(desired[0].path.contains(&a.id[..8]));
}
#[test]
fn build_playlist_desired_orders_members_and_marks_absent() {
let a = clip("id-a", "Song A", "alice");
let b = clip("id-b", "Song B", "alice");
let desired = build_desired(
&[&a, &b],
AudioFormat::Flac,
&modes_for(&[&a, &b], SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
let missing = clip("id-x", "Missing Song", "bob");
let members = vec![b.clone(), missing.clone(), a.clone()];
let inputs = vec![PlaylistInput {
id: "pl1",
name: "Road/Trip",
members: &members,
}];
let out = build_playlist_desired(&inputs, &desired);
assert_eq!(out.len(), 1);
let pl = &out[0];
assert_eq!(pl.id, "pl1");
assert_eq!(pl.path, "Road Trip.m3u8");
assert!(pl.content.starts_with("#EXTM3U\n#PLAYLIST:Road/Trip\n"));
let pos_b = pl.content.find(path_of(&desired, "id-b")).unwrap();
let pos_missing = pl.content.find("# (not in library) Missing Song").unwrap();
let pos_a = pl.content.find(path_of(&desired, "id-a")).unwrap();
assert!(pos_b < pos_missing && pos_missing < pos_a);
assert!(!pl.content.contains("Missing Song\nbob/"));
assert_eq!(pl.hash, content_hash(&pl.content));
}
#[test]
fn build_playlist_desired_builds_liked_and_multiple_in_order() {
let a = clip("id-a", "Song A", "alice");
let desired = build_desired(
&[&a],
AudioFormat::Flac,
&modes_for(&[&a], SourceMode::Mirror),
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
let members = vec![a.clone()];
let inputs = vec![
PlaylistInput {
id: "pl1",
name: "First",
members: &members,
},
PlaylistInput {
id: LIKED_PLAYLIST_ID,
name: "Liked Songs",
members: &members,
},
];
let out = build_playlist_desired(&inputs, &desired);
assert_eq!(out.len(), 2);
assert_eq!(out[0].id, "pl1");
assert_eq!(out[1].id, LIKED_PLAYLIST_ID);
assert_eq!(out[1].path, "Liked Songs.m3u8");
assert!(out[0].content.contains(path_of(&desired, "id-a")));
assert!(out[1].content.contains(path_of(&desired, "id-a")));
}
#[test]
fn build_playlist_desired_is_empty_for_no_inputs() {
assert!(build_playlist_desired(&[], &[]).is_empty());
}
}