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