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