1use std::collections::{BTreeSet, HashMap};
4use std::path::{Component, Path};
5
6use crate::client::Stem;
7use crate::config::{AudioFormat, StemFormat};
8use crate::extras::{M3u8Entry, render_clip_details, render_clip_lyrics, render_m3u8};
9use crate::ffmpeg::WebpEncodeSettings;
10use crate::hash::{
11 art_hash, art_url_hash, content_hash, embedded_art_hash, meta_hash, synced_lrc_source_hash,
12};
13use crate::lineage::LineageContext;
14use crate::model::Clip;
15use crate::naming::{
16 CharacterSet, NamingConfig, NamingRequest, render_clip_names, sanitise_name, stem_file_path,
17};
18use crate::reconcile::{
19 ArtifactKind, Desired, DesiredArtifact, DesiredStem, PlaylistDesired, SourceMode,
20};
21
22pub const LIKED_PLAYLIST_ID: &str = "liked";
27
28#[derive(Debug, Clone, Copy, Default)]
38pub struct ArtifactToggles {
39 pub animated_covers: bool,
40 pub details: bool,
41 pub lyrics: bool,
42 pub lrc: bool,
43 pub video: bool,
44 pub webp: WebpEncodeSettings,
47}
48
49pub struct PlaylistInput<'a> {
52 pub id: &'a str,
53 pub name: &'a str,
54 pub members: &'a [Clip],
55}
56
57pub fn build_desired(
86 clips: &[&Clip],
87 format: AudioFormat,
88 modes_by_id: &HashMap<String, Vec<SourceMode>>,
89 contexts: &HashMap<String, LineageContext>,
90 colliding_albums: &BTreeSet<String>,
91 toggles: ArtifactToggles,
92 naming: &NamingConfig,
93) -> Vec<Desired> {
94 let lineages: Vec<LineageContext> = clips
95 .iter()
96 .map(|clip| {
97 contexts
98 .get(&clip.id)
99 .cloned()
100 .unwrap_or_else(|| LineageContext::own_root(clip))
101 })
102 .collect();
103 let names = {
106 let requests: Vec<NamingRequest<'_>> = clips
107 .iter()
108 .zip(&lineages)
109 .map(|(clip, lineage)| NamingRequest { clip, lineage })
110 .collect();
111 render_clip_names(&requests, naming, colliding_albums)
112 };
113
114 clips
115 .iter()
116 .zip(names)
117 .zip(lineages)
118 .map(|((clip, name), lineage)| {
119 let base = rel_to_string(&name.relative_path);
121 let path = format!("{base}.{}", format.ext());
122 let meta_hash = meta_hash(clip, &lineage);
123 let modes = modes_by_id.get(&clip.id).cloned().unwrap_or_default();
124 debug_assert!(
127 !modes.is_empty(),
128 "clip {} has no modes in the union map",
129 clip.id
130 );
131 let artifacts = clip_artifacts(clip, &base, &lineage, toggles);
134 Desired {
135 clip: (*clip).clone(),
136 lineage,
137 path,
138 format,
139 meta_hash,
140 art_hash: embedded_art_hash(
141 clip,
142 toggles.animated_covers && format.embeds_animated_cover(),
143 &toggles.webp,
144 ),
145 modes,
146 trashed: clip.is_trashed,
147 private: false,
148 artifacts,
149 stems: None,
153 }
154 })
155 .collect()
156}
157
158pub fn clip_stems(
176 base: &str,
177 stems: &[Stem],
178 stem_format: StemFormat,
179 character_set: CharacterSet,
180) -> Vec<DesiredStem> {
181 let mut seen: BTreeSet<String> = BTreeSet::new();
182 let mut out = Vec::new();
183 for (index, stem) in stems.iter().enumerate() {
184 let base_key = if !stem.id.is_empty() {
185 stem.id.clone()
186 } else if !stem.label.is_empty() {
187 stem.label.clone()
188 } else {
189 format!("stem{index}")
190 };
191 let mut key = base_key.clone();
194 let mut suffix = 1;
195 while !seen.insert(key.clone()) {
196 key = format!("{base_key}-{suffix}");
197 suffix += 1;
198 }
199 let disambiguator = if stem.id.is_empty() {
202 key.as_str()
203 } else {
204 stem.id.as_str()
205 };
206 let format = if stem_format == StemFormat::Wav && stem.id.is_empty() {
209 StemFormat::Mp3
210 } else {
211 stem_format
212 };
213 let path = stem_file_path(
214 base,
215 &stem.label,
216 disambiguator,
217 format.ext(),
218 character_set,
219 );
220 out.push(DesiredStem {
221 key,
222 stem_id: stem.id.clone(),
223 path,
224 source_url: stem.url.clone(),
225 format,
226 hash: art_url_hash(&stem.url),
227 });
228 }
229 out
230}
231
232fn clip_artifacts(
256 clip: &Clip,
257 base: &str,
258 lineage: &LineageContext,
259 toggles: ArtifactToggles,
260) -> Vec<DesiredArtifact> {
261 let mut artifacts = Vec::new();
262 if let Some(url) = clip.selected_image_url().filter(|u| !u.is_empty()) {
263 artifacts.push(DesiredArtifact {
264 kind: ArtifactKind::CoverJpg,
265 path: format!("{base}.jpg"),
266 source_url: url.to_owned(),
267 hash: art_hash(clip),
268 content: None,
269 });
270 }
271 if toggles.details {
272 let text = render_clip_details(clip, lineage);
273 artifacts.push(DesiredArtifact {
274 kind: ArtifactKind::DetailsTxt,
275 path: format!("{base}.details.txt"),
276 source_url: String::new(),
277 hash: content_hash(&text),
278 content: Some(text),
279 });
280 }
281 if toggles.lyrics
282 && let Some(text) = render_clip_lyrics(clip)
283 {
284 artifacts.push(DesiredArtifact {
285 kind: ArtifactKind::LyricsTxt,
286 path: format!("{base}.lyrics.txt"),
287 source_url: String::new(),
288 hash: content_hash(&text),
289 content: Some(text),
290 });
291 }
292 if toggles.lrc {
293 artifacts.push(DesiredArtifact {
304 kind: ArtifactKind::Lrc,
305 path: format!("{base}.lrc"),
306 source_url: String::new(),
307 hash: synced_lrc_source_hash(&clip.id),
308 content: None,
309 });
310 }
311 if toggles.video && !clip.video_url.is_empty() {
312 artifacts.push(DesiredArtifact {
313 kind: ArtifactKind::VideoMp4,
314 path: format!("{base}.mp4"),
315 source_url: clip.video_url.clone(),
316 hash: art_url_hash(&clip.video_url),
317 content: None,
318 });
319 }
320 artifacts
321}
322
323pub fn build_playlist_desired(
339 inputs: &[PlaylistInput<'_>],
340 desired: &[Desired],
341) -> Vec<PlaylistDesired> {
342 let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
343 inputs
344 .iter()
345 .map(|input| {
346 let entries: Vec<M3u8Entry<'_>> = input
347 .members
348 .iter()
349 .map(|member| match by_id.get(member.id.as_str()) {
350 Some(d) => M3u8Entry {
351 title: d.clip.title.as_str(),
352 duration_secs: d.clip.duration,
353 relative_path: d.path.as_str(),
354 },
355 None => M3u8Entry {
356 title: member.title.as_str(),
357 duration_secs: member.duration,
358 relative_path: "",
359 },
360 })
361 .collect();
362 let content = render_m3u8(input.name, &entries);
363 let hash = content_hash(&content);
364 let path = format!("{}.m3u8", sanitise_name(input.name));
365 PlaylistDesired {
366 id: input.id.to_owned(),
367 name: input.name.to_owned(),
368 path,
369 content,
370 hash,
371 }
372 })
373 .collect()
374}
375
376pub(crate) fn rel_to_string(path: &Path) -> String {
384 path.components()
385 .filter_map(|component| match component {
386 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
387 _ => None,
388 })
389 .collect::<Vec<_>>()
390 .join("/")
391}
392
393#[cfg(test)]
394mod tests {
395 use std::collections::BTreeSet;
396 use std::collections::HashMap;
397 use std::path::PathBuf;
398
399 use super::*;
400 use crate::config::AudioFormat;
401 use crate::hash::{art_hash, art_url_hash, content_hash, synced_lrc_source_hash};
402 use crate::lineage::LineageContext;
403 use crate::naming::NamingConfig;
404 use crate::reconcile::{ArtifactKind, SourceMode};
405
406 fn clip(id: &str, title: &str, handle: &str) -> Clip {
407 Clip {
408 id: id.to_owned(),
409 title: title.to_owned(),
410 handle: handle.to_owned(),
411 display_name: handle.to_owned(),
412 ..Default::default()
413 }
414 }
415
416 fn no_contexts() -> HashMap<String, LineageContext> {
417 HashMap::new()
418 }
419
420 fn no_collisions() -> BTreeSet<String> {
421 BTreeSet::new()
422 }
423
424 fn modes_for(clips: &[&Clip], mode: SourceMode) -> HashMap<String, Vec<SourceMode>> {
425 clips.iter().map(|c| (c.id.clone(), vec![mode])).collect()
426 }
427
428 fn art_clip(id: &str) -> Clip {
429 Clip {
430 image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
431 ..clip(id, "Song", "alice")
432 }
433 }
434
435 fn path_of<'a>(desired: &'a [Desired], id: &str) -> &'a str {
436 desired
437 .iter()
438 .find(|d| d.clip.id == id)
439 .map(|d| d.path.as_str())
440 .expect("clip in desired set")
441 }
442
443 #[test]
444 fn build_desired_appends_extension_and_mode() {
445 let a = clip("id-a", "Song A", "alice");
446 let clips = [&a];
447 let desired = build_desired(
448 &clips,
449 AudioFormat::Flac,
450 &modes_for(&clips, SourceMode::Mirror),
451 &no_contexts(),
452 &no_collisions(),
453 ArtifactToggles::default(),
454 &NamingConfig::default(),
455 );
456 assert_eq!(desired.len(), 1);
457 assert!(
458 desired[0].path.ends_with(".flac"),
459 "path: {}",
460 desired[0].path
461 );
462 assert_eq!(desired[0].format, AudioFormat::Flac);
463 assert_eq!(desired[0].modes, vec![SourceMode::Mirror]);
464 assert!(!desired[0].trashed);
465 assert!(!desired[0].private);
466 let lineage = LineageContext::own_root(&a);
467 assert_eq!(desired[0].meta_hash, crate::hash::meta_hash(&a, &lineage));
468 assert_eq!(desired[0].art_hash, art_hash(&a));
469 assert_eq!(desired[0].lineage, lineage);
470 }
471
472 #[test]
473 fn build_desired_carries_the_trashed_flag_from_the_clip() {
474 let mut gone = clip("id-gone", "Removed", "alice");
475 gone.is_trashed = true;
476 let live = clip("id-live", "Kept", "alice");
477 let clips = [&gone, &live];
478 let desired = build_desired(
479 &clips,
480 AudioFormat::Flac,
481 &modes_for(&clips, SourceMode::Mirror),
482 &no_contexts(),
483 &no_collisions(),
484 ArtifactToggles::default(),
485 &NamingConfig::default(),
486 );
487 assert!(desired[0].trashed, "a trashed clip is marked trashed");
488 assert!(!desired[1].trashed, "a live clip is not");
489 }
490
491 #[test]
492 fn build_desired_uses_supplied_lineage_context() {
493 use crate::lineage::ResolveStatus;
494
495 let a = clip("child-1", "Remix", "alice");
496 let clips = [&a];
497 let lineage = LineageContext {
498 root_id: "root-1".to_owned(),
499 root_title: "Original".to_owned(),
500 root_date: String::new(),
501 parent_id: "root-1".to_owned(),
502 edge_type: None,
503 status: ResolveStatus::Resolved,
504 };
505 let contexts: HashMap<String, LineageContext> =
506 [(a.id.clone(), lineage.clone())].into_iter().collect();
507 let desired = build_desired(
508 &clips,
509 AudioFormat::Flac,
510 &modes_for(&clips, SourceMode::Mirror),
511 &contexts,
512 &no_collisions(),
513 ArtifactToggles::default(),
514 &NamingConfig::default(),
515 );
516 assert!(
517 desired[0].path.contains("/Original/"),
518 "path: {}",
519 desired[0].path
520 );
521 assert_eq!(desired[0].lineage, lineage);
522 assert_eq!(desired[0].meta_hash, crate::hash::meta_hash(&a, &lineage));
523 }
524
525 #[test]
526 fn lineage_is_stable_when_a_later_resolution_fails() {
527 use crate::graph::LineageStore;
528 use crate::lineage::{Resolution, ResolveStatus, RootInfo};
529
530 let root = Clip {
531 id: "root-break".into(),
532 title: "Break Through".into(),
533 clip_type: "gen".into(),
534 handle: "alice".into(),
535 display_name: "alice".into(),
536 ..Default::default()
537 };
538 let child = Clip {
539 id: "child-remix".into(),
540 title: "Remix".into(),
541 clip_type: "gen".into(),
542 task: "cover".into(),
543 cover_clip_id: "root-break".into(),
544 edited_clip_id: "root-break".into(),
545 handle: "alice".into(),
546 display_name: "alice".into(),
547 ..Default::default()
548 };
549 let clips = [&root, &child];
550
551 let contexts_of = |store: &LineageStore| -> HashMap<String, LineageContext> {
552 clips
553 .iter()
554 .map(|c| (c.id.clone(), store.context_for(c)))
555 .collect()
556 };
557
558 let mut roots = HashMap::new();
559 for id in ["root-break", "child-remix"] {
560 roots.insert(
561 id.to_owned(),
562 RootInfo {
563 root_id: "root-break".into(),
564 root_title: "Break Through".into(),
565 status: ResolveStatus::Resolved,
566 },
567 );
568 }
569 let resolution = Resolution {
570 roots,
571 gap_filled: Vec::new(),
572 bridges: Vec::new(),
573 };
574 let mut store = LineageStore::new();
575 store.update(&[root.clone(), child.clone()], &resolution, "t1");
576
577 let cycle1 = build_desired(
578 &clips,
579 AudioFormat::Flac,
580 &modes_for(&clips, SourceMode::Mirror),
581 &contexts_of(&store),
582 &store.colliding_root_titles(),
583 ArtifactToggles::default(),
584 &NamingConfig::default(),
585 );
586 let child1 = cycle1.iter().find(|d| d.clip.id == "child-remix").unwrap();
587 assert!(
588 child1.path.contains("/Break Through/"),
589 "the remix should folder under its root album, got {}",
590 child1.path
591 );
592
593 let cycle2 = build_desired(
594 &clips,
595 AudioFormat::Flac,
596 &modes_for(&clips, SourceMode::Mirror),
597 &contexts_of(&store),
598 &store.colliding_root_titles(),
599 ArtifactToggles::default(),
600 &NamingConfig::default(),
601 );
602 for (a, b) in cycle1.iter().zip(&cycle2) {
603 assert_eq!(a.path, b.path, "album path drifted for {}", a.clip.id);
604 assert_eq!(
605 a.meta_hash, b.meta_hash,
606 "meta_hash drifted for {}",
607 a.clip.id
608 );
609 }
610
611 let own = LineageContext::own_root(&child);
612 assert_ne!(
613 crate::hash::meta_hash(&child, &own),
614 child1.meta_hash,
615 "own-root fallback must differ from the store-driven hash"
616 );
617 }
618
619 #[test]
620 fn build_desired_disambiguates_collisions() {
621 let a = clip("id-a", "Same", "alice");
622 let b = clip("id-b", "Same", "alice");
623 let clips = [&a, &b];
624 let desired = build_desired(
625 &clips,
626 AudioFormat::Mp3,
627 &modes_for(&clips, SourceMode::Copy),
628 &no_contexts(),
629 &no_collisions(),
630 ArtifactToggles::default(),
631 &NamingConfig::default(),
632 );
633 assert_ne!(desired[0].path, desired[1].path);
634 assert!(desired.iter().all(|d| d.path.ends_with(".mp3")));
635 assert!(desired.iter().all(|d| d.modes == vec![SourceMode::Copy]));
636 }
637
638 #[test]
639 fn build_desired_uses_forward_slashes() {
640 let a = clip("id-a", "Song A", "alice");
641 let clips = [&a];
642 let desired = build_desired(
643 &clips,
644 AudioFormat::Flac,
645 &modes_for(&clips, SourceMode::Mirror),
646 &no_contexts(),
647 &no_collisions(),
648 ArtifactToggles::default(),
649 &NamingConfig::default(),
650 );
651 assert!(!desired[0].path.contains('\\'));
652 assert!(desired[0].path.contains('/'));
653 }
654
655 #[test]
656 fn rel_to_string_normalises_os_separators_to_forward_slash() {
657 let mut path = PathBuf::new();
663 for part in ["alice", "Album Name", "alice-Song [clipaaaa]"] {
664 path.push(part);
665 }
666 let rendered = rel_to_string(&path);
667 assert_eq!(rendered, "alice/Album Name/alice-Song [clipaaaa]");
668 assert!(
669 !rendered.contains('\\'),
670 "rendered a platform separator: {rendered}"
671 );
672 }
673
674 #[test]
675 fn cover_album_and_stem_paths_use_forward_slashes() {
676 let a = art_clip("clipaaaa-1234");
681 let clips = [&a];
682 let desired = build_desired(
683 &clips,
684 AudioFormat::Flac,
685 &modes_for(&clips, SourceMode::Mirror),
686 &no_contexts(),
687 &no_collisions(),
688 ArtifactToggles::default(),
689 &NamingConfig::default(),
690 );
691 let d = &desired[0];
692
693 let cover = d
694 .artifacts
695 .iter()
696 .find(|art| art.kind == ArtifactKind::CoverJpg)
697 .expect("an art-bearing clip yields a cover.jpg");
698 assert!(
699 cover.path.contains('/') && !cover.path.contains('\\'),
700 "cover: {}",
701 cover.path
702 );
703
704 let albums =
705 crate::reconcile::album_desired(&desired, false, false, WebpEncodeSettings::default());
706 let folder_jpg = albums[0]
707 .folder_jpg
708 .as_ref()
709 .expect("the album has a folder.jpg");
710 assert!(
711 folder_jpg.path.contains('/') && !folder_jpg.path.contains('\\'),
712 "folder.jpg: {}",
713 folder_jpg.path
714 );
715
716 let base = d.path.strip_suffix(".flac").expect("a .flac audio path");
717 let stems = clip_stems(
718 base,
719 &[Stem {
720 id: "stemvocal-9f8e".to_owned(),
721 label: "Vocals".to_owned(),
722 url: "https://cdn.suno.ai/stem.mp3".to_owned(),
723 }],
724 StemFormat::Mp3,
725 CharacterSet::Unicode,
726 );
727 assert!(
728 stems[0].path.contains('/') && !stems[0].path.contains('\\'),
729 "stem: {}",
730 stems[0].path
731 );
732 }
733
734 #[test]
735 fn build_desired_emits_cover_jpg_next_to_audio() {
736 let a = art_clip("id-a");
737 let clips = [&a];
738 let desired = build_desired(
739 &clips,
740 AudioFormat::Flac,
741 &modes_for(&clips, SourceMode::Mirror),
742 &no_contexts(),
743 &no_collisions(),
744 ArtifactToggles::default(),
745 &NamingConfig::default(),
746 );
747 let base = desired[0].path.strip_suffix(".flac").unwrap();
748 assert_eq!(desired[0].artifacts.len(), 1);
749 let jpg = &desired[0].artifacts[0];
750 assert_eq!(jpg.kind, ArtifactKind::CoverJpg);
751 assert_eq!(jpg.path, format!("{base}.jpg"));
752 assert_eq!(jpg.source_url, a.selected_image_url().unwrap());
753 assert_eq!(jpg.hash, art_hash(&a));
754 }
755
756 #[test]
757 fn build_desired_omits_cover_jpg_when_art_is_empty() {
758 let a = clip("id-a", "Song", "alice");
759 assert!(a.selected_image_url().is_none());
760 let clips = [&a];
761 let desired = build_desired(
762 &clips,
763 AudioFormat::Flac,
764 &modes_for(&clips, SourceMode::Mirror),
765 &no_contexts(),
766 &no_collisions(),
767 ArtifactToggles {
768 animated_covers: true,
769 ..Default::default()
770 },
771 &NamingConfig::default(),
772 );
773 assert!(desired[0].artifacts.is_empty());
774 }
775
776 #[test]
777 fn animated_covers_embed_via_art_hash_not_a_webp_sidecar() {
778 let with_video = Clip {
779 video_cover_url: "https://cdn.suno.ai/id-a/video.mp4".to_owned(),
780 ..art_clip("id-a")
781 };
782 let clips = [&with_video];
783
784 let off = build_desired(
786 &clips,
787 AudioFormat::Flac,
788 &modes_for(&clips, SourceMode::Mirror),
789 &no_contexts(),
790 &no_collisions(),
791 ArtifactToggles::default(),
792 &NamingConfig::default(),
793 );
794 assert_eq!(off[0].artifacts.len(), 1);
795 assert_eq!(off[0].artifacts[0].kind, ArtifactKind::CoverJpg);
796 assert_eq!(off[0].art_hash, crate::hash::art_hash(&with_video));
797
798 let on = build_desired(
802 &clips,
803 AudioFormat::Flac,
804 &modes_for(&clips, SourceMode::Mirror),
805 &no_contexts(),
806 &no_collisions(),
807 ArtifactToggles {
808 animated_covers: true,
809 ..Default::default()
810 },
811 &NamingConfig::default(),
812 );
813 assert!(
814 on[0]
815 .artifacts
816 .iter()
817 .all(|art| art.kind != ArtifactKind::CoverWebp)
818 );
819 assert_eq!(
820 on[0]
821 .artifacts
822 .iter()
823 .filter(|a| a.kind == ArtifactKind::CoverJpg)
824 .count(),
825 1
826 );
827 assert_ne!(
828 on[0].art_hash, off[0].art_hash,
829 "embed intent drifts the art hash"
830 );
831 assert_eq!(
832 on[0].art_hash,
833 crate::hash::embedded_art_hash(&with_video, true, &WebpEncodeSettings::default())
834 );
835
836 let no_video = art_clip("id-b");
838 let clips = [&no_video];
839 let on_novideo = build_desired(
840 &clips,
841 AudioFormat::Flac,
842 &modes_for(&clips, SourceMode::Mirror),
843 &no_contexts(),
844 &no_collisions(),
845 ArtifactToggles {
846 animated_covers: true,
847 ..Default::default()
848 },
849 &NamingConfig::default(),
850 );
851 assert!(
852 on_novideo[0]
853 .artifacts
854 .iter()
855 .all(|art| art.kind != ArtifactKind::CoverWebp)
856 );
857 assert_eq!(on_novideo[0].art_hash, crate::hash::art_hash(&no_video));
858
859 let clips = [&with_video];
862 let alac = build_desired(
863 &clips,
864 AudioFormat::Alac,
865 &modes_for(&clips, SourceMode::Mirror),
866 &no_contexts(),
867 &no_collisions(),
868 ArtifactToggles {
869 animated_covers: true,
870 ..Default::default()
871 },
872 &NamingConfig::default(),
873 );
874 assert_eq!(alac[0].art_hash, crate::hash::art_hash(&with_video));
875 }
876
877 #[test]
878 fn build_desired_emits_video_mp4_only_when_enabled_and_video_present() {
879 let with_video = Clip {
880 video_url: "https://cdn.suno.ai/id-a/video.mp4".to_owned(),
881 ..art_clip("id-a")
882 };
883 let clips = [&with_video];
884
885 let desired = build_desired(
886 &clips,
887 AudioFormat::Flac,
888 &modes_for(&clips, SourceMode::Mirror),
889 &no_contexts(),
890 &no_collisions(),
891 ArtifactToggles::default(),
892 &NamingConfig::default(),
893 );
894 assert!(
895 desired[0]
896 .artifacts
897 .iter()
898 .all(|art| art.kind != ArtifactKind::VideoMp4)
899 );
900
901 let desired = build_desired(
902 &clips,
903 AudioFormat::Flac,
904 &modes_for(&clips, SourceMode::Mirror),
905 &no_contexts(),
906 &no_collisions(),
907 ArtifactToggles {
908 video: true,
909 ..Default::default()
910 },
911 &NamingConfig::default(),
912 );
913 let base = desired[0].path.strip_suffix(".flac").unwrap();
914 let video = desired[0]
915 .artifacts
916 .iter()
917 .find(|art| art.kind == ArtifactKind::VideoMp4)
918 .expect("video expected");
919 assert_eq!(video.path, format!("{base}.mp4"));
920 assert_eq!(video.source_url, with_video.video_url);
921 assert_eq!(video.hash, art_url_hash(&with_video.video_url));
922 assert!(video.content.is_none());
923
924 let no_video = art_clip("id-b");
925 let clips = [&no_video];
926 let desired = build_desired(
927 &clips,
928 AudioFormat::Flac,
929 &modes_for(&clips, SourceMode::Mirror),
930 &no_contexts(),
931 &no_collisions(),
932 ArtifactToggles {
933 video: true,
934 ..Default::default()
935 },
936 &NamingConfig::default(),
937 );
938 assert!(
939 desired[0]
940 .artifacts
941 .iter()
942 .all(|art| art.kind != ArtifactKind::VideoMp4)
943 );
944 }
945
946 #[test]
947 fn build_desired_emits_details_sidecar_only_when_enabled() {
948 use crate::extras::render_clip_details;
949 use crate::hash::content_hash;
950
951 let a = clip("id-a", "Song", "alice");
952 let clips = [&a];
953
954 let off = build_desired(
955 &clips,
956 AudioFormat::Flac,
957 &modes_for(&clips, SourceMode::Mirror),
958 &no_contexts(),
959 &no_collisions(),
960 ArtifactToggles::default(),
961 &NamingConfig::default(),
962 );
963 assert!(
964 off[0]
965 .artifacts
966 .iter()
967 .all(|art| art.kind != ArtifactKind::DetailsTxt)
968 );
969
970 let on = build_desired(
971 &clips,
972 AudioFormat::Flac,
973 &modes_for(&clips, SourceMode::Mirror),
974 &no_contexts(),
975 &no_collisions(),
976 ArtifactToggles {
977 details: true,
978 ..Default::default()
979 },
980 &NamingConfig::default(),
981 );
982 let base = on[0].path.strip_suffix(".flac").unwrap();
983 let details = on[0]
984 .artifacts
985 .iter()
986 .find(|art| art.kind == ArtifactKind::DetailsTxt)
987 .expect("details sidecar expected");
988 assert_eq!(details.path, format!("{base}.details.txt"));
989 assert_eq!(details.source_url, "");
990 let body = render_clip_details(&a, &LineageContext::own_root(&a));
991 assert_eq!(details.content.as_deref(), Some(body.as_str()));
992 assert_eq!(details.hash, content_hash(&body));
993 }
994
995 #[test]
996 fn build_desired_emits_lyrics_sidecar_only_when_enabled_and_present() {
997 let with_lyrics = Clip {
998 lyrics: "la la la".to_owned(),
999 ..clip("id-a", "Song", "alice")
1000 };
1001 let clips = [&with_lyrics];
1002
1003 let off = build_desired(
1004 &clips,
1005 AudioFormat::Flac,
1006 &modes_for(&clips, SourceMode::Mirror),
1007 &no_contexts(),
1008 &no_collisions(),
1009 ArtifactToggles::default(),
1010 &NamingConfig::default(),
1011 );
1012 assert!(
1013 off[0]
1014 .artifacts
1015 .iter()
1016 .all(|art| art.kind != ArtifactKind::LyricsTxt)
1017 );
1018
1019 let on = build_desired(
1020 &clips,
1021 AudioFormat::Flac,
1022 &modes_for(&clips, SourceMode::Mirror),
1023 &no_contexts(),
1024 &no_collisions(),
1025 ArtifactToggles {
1026 lyrics: true,
1027 ..Default::default()
1028 },
1029 &NamingConfig::default(),
1030 );
1031 let base = on[0].path.strip_suffix(".flac").unwrap();
1032 let lyrics = on[0]
1033 .artifacts
1034 .iter()
1035 .find(|art| art.kind == ArtifactKind::LyricsTxt)
1036 .expect("lyrics sidecar expected");
1037 assert_eq!(lyrics.path, format!("{base}.lyrics.txt"));
1038 assert_eq!(lyrics.source_url, "");
1039 assert_eq!(lyrics.content.as_deref(), Some("la la la\n"));
1040 assert_eq!(lyrics.hash, content_hash("la la la\n"));
1041 }
1042
1043 #[test]
1044 fn build_desired_emits_lrc_sidecar_only_when_enabled() {
1045 let with_lyrics = Clip {
1046 lyrics: "la la la".to_owned(),
1047 ..clip("id-a", "Song", "alice")
1048 };
1049 let clips = [&with_lyrics];
1050
1051 let off = build_desired(
1052 &clips,
1053 AudioFormat::Flac,
1054 &modes_for(&clips, SourceMode::Mirror),
1055 &no_contexts(),
1056 &no_collisions(),
1057 ArtifactToggles::default(),
1058 &NamingConfig::default(),
1059 );
1060 assert!(
1061 off[0]
1062 .artifacts
1063 .iter()
1064 .all(|art| art.kind != ArtifactKind::Lrc)
1065 );
1066
1067 let on = build_desired(
1068 &clips,
1069 AudioFormat::Flac,
1070 &modes_for(&clips, SourceMode::Mirror),
1071 &no_contexts(),
1072 &no_collisions(),
1073 ArtifactToggles {
1074 lrc: true,
1075 ..Default::default()
1076 },
1077 &NamingConfig::default(),
1078 );
1079 let base = on[0].path.strip_suffix(".flac").unwrap();
1080 let lrc = on[0]
1081 .artifacts
1082 .iter()
1083 .find(|art| art.kind == ArtifactKind::Lrc)
1084 .expect("lrc sidecar expected");
1085 assert_eq!(lrc.path, format!("{base}.lrc"));
1086 assert_eq!(lrc.source_url, "");
1087 assert_eq!(lrc.content, None);
1088 assert_eq!(lrc.hash, synced_lrc_source_hash(&with_lyrics.id));
1089 }
1090
1091 #[test]
1092 fn build_desired_emits_lrc_sidecar_from_prompt_when_feed_omits_lyrics() {
1093 let prompt_only = Clip {
1094 prompt: "the sung words live here".to_owned(),
1095 ..clip("id-a", "Song", "alice")
1096 };
1097 assert!(prompt_only.lyrics.is_empty());
1098 let clips = [&prompt_only];
1099 let desired = build_desired(
1100 &clips,
1101 AudioFormat::Flac,
1102 &modes_for(&clips, SourceMode::Mirror),
1103 &no_contexts(),
1104 &no_collisions(),
1105 ArtifactToggles {
1106 lrc: true,
1107 ..Default::default()
1108 },
1109 &NamingConfig::default(),
1110 );
1111 let lrc = desired[0]
1112 .artifacts
1113 .iter()
1114 .find(|art| art.kind == ArtifactKind::Lrc)
1115 .expect("lrc sidecar expected");
1116 assert_eq!(lrc.content, None);
1117 assert_eq!(lrc.hash, synced_lrc_source_hash(&prompt_only.id));
1118 }
1119
1120 #[test]
1121 fn build_desired_emits_lrc_sidecar_even_when_feed_has_no_lyrics_or_prompt() {
1122 let bare = clip("id-a", "Song", "alice");
1123 assert!(bare.lyrics.is_empty() && bare.prompt.is_empty());
1124 let clips = [&bare];
1125 let desired = build_desired(
1126 &clips,
1127 AudioFormat::Flac,
1128 &modes_for(&clips, SourceMode::Mirror),
1129 &no_contexts(),
1130 &no_collisions(),
1131 ArtifactToggles {
1132 lrc: true,
1133 ..Default::default()
1134 },
1135 &NamingConfig::default(),
1136 );
1137 let lrc = desired[0]
1138 .artifacts
1139 .iter()
1140 .find(|art| art.kind == ArtifactKind::Lrc)
1141 .expect("lrc sidecar expected even with no feed lyrics/prompt");
1142 assert_eq!(lrc.content, None);
1143 assert_eq!(lrc.hash, synced_lrc_source_hash(&bare.id));
1144 }
1145
1146 #[test]
1147 fn build_desired_omits_lyrics_sidecar_when_clip_has_no_lyrics() {
1148 let no_lyrics = clip("id-a", "Song", "alice");
1149 assert!(no_lyrics.lyrics.is_empty());
1150 let clips = [&no_lyrics];
1151 let desired = build_desired(
1152 &clips,
1153 AudioFormat::Flac,
1154 &modes_for(&clips, SourceMode::Mirror),
1155 &no_contexts(),
1156 &no_collisions(),
1157 ArtifactToggles {
1158 lyrics: true,
1159 ..Default::default()
1160 },
1161 &NamingConfig::default(),
1162 );
1163 assert!(
1164 desired[0]
1165 .artifacts
1166 .iter()
1167 .all(|art| art.kind != ArtifactKind::LyricsTxt)
1168 );
1169 }
1170
1171 #[test]
1172 fn build_desired_text_sidecars_are_independent() {
1173 let full = Clip {
1174 lyrics: "words".to_owned(),
1175 ..art_clip("id-a")
1176 };
1177 let clips = [&full];
1178 let desired = build_desired(
1179 &clips,
1180 AudioFormat::Flac,
1181 &modes_for(&clips, SourceMode::Mirror),
1182 &no_contexts(),
1183 &no_collisions(),
1184 ArtifactToggles {
1185 details: true,
1186 lyrics: true,
1187 ..Default::default()
1188 },
1189 &NamingConfig::default(),
1190 );
1191 let base = desired[0].path.strip_suffix(".flac").unwrap();
1192 let kinds: BTreeSet<ArtifactKind> = desired[0].artifacts.iter().map(|a| a.kind).collect();
1193 assert!(kinds.contains(&ArtifactKind::CoverJpg));
1194 assert!(kinds.contains(&ArtifactKind::DetailsTxt));
1195 assert!(kinds.contains(&ArtifactKind::LyricsTxt));
1196 let path_of_kind = |k: ArtifactKind| {
1197 desired[0]
1198 .artifacts
1199 .iter()
1200 .find(|a| a.kind == k)
1201 .unwrap()
1202 .path
1203 .clone()
1204 };
1205 assert_eq!(
1206 path_of_kind(ArtifactKind::DetailsTxt),
1207 format!("{base}.details.txt")
1208 );
1209 assert_eq!(
1210 path_of_kind(ArtifactKind::LyricsTxt),
1211 format!("{base}.lyrics.txt")
1212 );
1213 }
1214
1215 #[test]
1216 fn build_desired_one_pass_disambiguates_and_stamps_modes() {
1217 let a = clip("lib-1", "Song", "alice");
1218 let b = clip("pl-1", "Song", "alice");
1219 let clips = [&a, &b];
1220 let mut modes = HashMap::new();
1221 modes.insert("lib-1".to_owned(), vec![SourceMode::Copy]);
1222 modes.insert(
1223 "pl-1".to_owned(),
1224 vec![SourceMode::Mirror, SourceMode::Copy],
1225 );
1226 let desired = build_desired(
1227 &clips,
1228 AudioFormat::Flac,
1229 &modes,
1230 &no_contexts(),
1231 &no_collisions(),
1232 ArtifactToggles::default(),
1233 &NamingConfig::default(),
1234 );
1235 assert_eq!(desired.len(), 2);
1236 assert_ne!(desired[0].path, desired[1].path);
1237 assert_eq!(desired[1].modes, vec![SourceMode::Mirror, SourceMode::Copy]);
1238 }
1239
1240 #[test]
1241 fn build_desired_respects_custom_naming_config() {
1242 use crate::naming::CharacterSet;
1243
1244 let a = clip("abcdefgh-1234", "Song A", "alice");
1245 let clips = [&a];
1246 let custom = NamingConfig {
1247 template: "{title}/{id8}".to_owned(),
1248 character_set: CharacterSet::Ascii,
1249 ..NamingConfig::default()
1250 };
1251 let desired = build_desired(
1252 &clips,
1253 AudioFormat::Flac,
1254 &HashMap::from([("abcdefgh-1234".to_owned(), vec![SourceMode::Mirror])]),
1255 &no_contexts(),
1256 &no_collisions(),
1257 ArtifactToggles::default(),
1258 &custom,
1259 );
1260 assert!(
1261 desired[0].path.starts_with("Song A/"),
1262 "path: {}",
1263 desired[0].path
1264 );
1265 assert!(desired[0].path.contains(&a.id[..8]));
1266 }
1267
1268 #[test]
1269 fn build_playlist_desired_orders_members_and_marks_absent() {
1270 let a = clip("id-a", "Song A", "alice");
1271 let b = clip("id-b", "Song B", "alice");
1272 let desired = build_desired(
1273 &[&a, &b],
1274 AudioFormat::Flac,
1275 &modes_for(&[&a, &b], SourceMode::Mirror),
1276 &no_contexts(),
1277 &no_collisions(),
1278 ArtifactToggles::default(),
1279 &NamingConfig::default(),
1280 );
1281 let missing = clip("id-x", "Missing Song", "bob");
1282 let members = vec![b.clone(), missing.clone(), a.clone()];
1283 let inputs = vec![PlaylistInput {
1284 id: "pl1",
1285 name: "Road/Trip",
1286 members: &members,
1287 }];
1288
1289 let out = build_playlist_desired(&inputs, &desired);
1290 assert_eq!(out.len(), 1);
1291 let pl = &out[0];
1292 assert_eq!(pl.id, "pl1");
1293 assert_eq!(pl.path, "Road Trip.m3u8");
1294 assert!(pl.content.starts_with("#EXTM3U\n#PLAYLIST:Road/Trip\n"));
1295
1296 let pos_b = pl.content.find(path_of(&desired, "id-b")).unwrap();
1297 let pos_missing = pl.content.find("# (not in library) Missing Song").unwrap();
1298 let pos_a = pl.content.find(path_of(&desired, "id-a")).unwrap();
1299 assert!(pos_b < pos_missing && pos_missing < pos_a);
1300 assert!(!pl.content.contains("Missing Song\nbob/"));
1301 assert_eq!(pl.hash, content_hash(&pl.content));
1302 }
1303
1304 #[test]
1305 fn build_playlist_desired_builds_liked_and_multiple_in_order() {
1306 let a = clip("id-a", "Song A", "alice");
1307 let desired = build_desired(
1308 &[&a],
1309 AudioFormat::Flac,
1310 &modes_for(&[&a], SourceMode::Mirror),
1311 &no_contexts(),
1312 &no_collisions(),
1313 ArtifactToggles::default(),
1314 &NamingConfig::default(),
1315 );
1316 let members = vec![a.clone()];
1317 let inputs = vec![
1318 PlaylistInput {
1319 id: "pl1",
1320 name: "First",
1321 members: &members,
1322 },
1323 PlaylistInput {
1324 id: LIKED_PLAYLIST_ID,
1325 name: "Liked Songs",
1326 members: &members,
1327 },
1328 ];
1329
1330 let out = build_playlist_desired(&inputs, &desired);
1331 assert_eq!(out.len(), 2);
1332 assert_eq!(out[0].id, "pl1");
1333 assert_eq!(out[1].id, LIKED_PLAYLIST_ID);
1334 assert_eq!(out[1].path, "Liked Songs.m3u8");
1335 assert!(out[0].content.contains(path_of(&desired, "id-a")));
1336 assert!(out[1].content.contains(path_of(&desired, "id-a")));
1337 }
1338
1339 #[test]
1340 fn build_playlist_desired_is_empty_for_no_inputs() {
1341 assert!(build_playlist_desired(&[], &[]).is_empty());
1342 }
1343}