use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Component, Path};
use crate::extras::{M3u8Entry, render_clip_details, render_m3u8};
use crate::hash::{
art_hash, art_url_hash, content_hash, embedded_art_hash, lyrics_txt_source_hash, meta_hash,
synced_lrc_source_hash, webp_art_hash,
};
use crate::lineage::LineageContext;
use crate::model::Clip;
use crate::model::Stem;
use crate::naming::{
CharacterSet, NamingConfig, NamingRequest, render_clip_names, sanitise_name, stem_file_path,
};
use crate::reconcile::{AlbumDesired, Desired, DesiredArtifact, DesiredStem, PlaylistDesired};
use crate::vocab::{ArtifactKind, AudioFormat, SourceMode, StemFormat, WebpEncodeSettings};
pub const LIKED_PLAYLIST_ID: &str = "liked";
#[derive(Debug, Clone, Copy, Default)]
pub struct ArtifactToggles {
pub animated_covers: bool,
pub details: bool,
pub lyrics: bool,
pub lrc: bool,
pub video: bool,
pub webp: WebpEncodeSettings,
}
pub struct PlaylistInput<'a> {
pub id: &'a str,
pub name: &'a str,
pub members: &'a [Clip],
}
#[allow(clippy::too_many_arguments)]
pub fn build_desired(
clips: &[&Clip],
format: AudioFormat,
modes_by_id: &HashMap<String, Vec<SourceMode>>,
contexts: &HashMap<String, LineageContext>,
colliding_albums: &BTreeSet<String>,
colliding_ids: &BTreeSet<String>,
toggles: ArtifactToggles,
naming: &NamingConfig,
) -> Vec<Desired> {
let lineages: Vec<LineageContext> = clips
.iter()
.map(|clip| {
contexts
.get(&clip.id)
.cloned()
.unwrap_or_else(|| LineageContext::own_root(clip))
})
.collect();
let names = {
let requests: Vec<NamingRequest<'_>> = clips
.iter()
.zip(&lineages)
.map(|(clip, lineage)| NamingRequest { clip, lineage })
.collect();
render_clip_names(&requests, naming, colliding_albums, colliding_ids)
};
clips
.iter()
.zip(names)
.zip(lineages)
.map(|((clip, name), lineage)| {
let base = rel_to_string(&name.relative_path);
let path = format!("{base}.{}", format.ext());
let meta_hash = meta_hash(clip, &lineage);
let modes = modes_by_id.get(&clip.id).cloned().unwrap_or_default();
debug_assert!(
!modes.is_empty(),
"clip {} has no modes in the union map",
clip.id
);
let artifacts = clip_artifacts(clip, &base, &lineage, toggles);
Desired {
clip: (*clip).clone(),
lineage,
path,
format,
meta_hash,
art_hash: embedded_art_hash(
clip,
toggles.animated_covers && format.embeds_animated_cover(),
&toggles.webp,
),
embedded_lyrics_hash: String::new(),
modes,
trashed: clip.is_trashed,
private: false,
artifacts,
stems: None,
}
})
.collect()
}
pub fn clip_stems(
base: &str,
stems: &[Stem],
stem_format: StemFormat,
character_set: CharacterSet,
) -> Vec<DesiredStem> {
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out = Vec::new();
for (index, stem) in stems.iter().enumerate() {
let base_key = if !stem.id.is_empty() {
stem.id.clone()
} else if !stem.label.is_empty() {
stem.label.clone()
} else {
format!("stem{index}")
};
let mut key = base_key.clone();
let mut suffix = 1;
while !seen.insert(key.clone()) {
key = format!("{base_key}-{suffix}");
suffix += 1;
}
let disambiguator = if stem.id.is_empty() {
key.as_str()
} else {
stem.id.as_str()
};
let format = if stem_format == StemFormat::Wav && stem.id.is_empty() {
StemFormat::Mp3
} else {
stem_format
};
let path = stem_file_path(
base,
&stem.label,
disambiguator,
format.ext(),
character_set,
);
out.push(DesiredStem {
key,
stem_id: stem.id.clone(),
path,
source_url: stem.url.clone(),
format,
hash: art_url_hash(&stem.url),
});
}
out
}
fn clip_artifacts(
clip: &Clip,
base: &str,
lineage: &LineageContext,
toggles: ArtifactToggles,
) -> Vec<DesiredArtifact> {
let mut artifacts = Vec::new();
if let Some(url) = clip.selected_image_url().filter(|u| !u.is_empty()) {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::CoverJpg,
path: sidecar_path(base, ArtifactKind::CoverJpg),
source_url: url.to_owned(),
hash: art_hash(clip),
content: None,
});
}
if toggles.details {
let text = render_clip_details(clip, lineage);
artifacts.push(DesiredArtifact {
kind: ArtifactKind::DetailsTxt,
path: sidecar_path(base, ArtifactKind::DetailsTxt),
source_url: String::new(),
hash: content_hash(&text),
content: Some(text),
});
}
if toggles.lyrics {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::LyricsTxt,
path: sidecar_path(base, ArtifactKind::LyricsTxt),
source_url: String::new(),
hash: lyrics_txt_source_hash(&clip.id),
content: None,
});
}
if toggles.lrc {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::Lrc,
path: sidecar_path(base, ArtifactKind::Lrc),
source_url: String::new(),
hash: synced_lrc_source_hash(&clip.id),
content: None,
});
}
if toggles.video && !clip.video_url.is_empty() {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::VideoMp4,
path: sidecar_path(base, ArtifactKind::VideoMp4),
source_url: clip.video_url.clone(),
hash: art_url_hash(&clip.video_url),
content: None,
});
}
artifacts
}
fn sidecar_path(base: &str, kind: ArtifactKind) -> String {
format!(
"{base}{}",
kind.sidecar_suffix()
.expect("per-clip sidecar kind has a suffix")
)
}
pub fn build_playlist_desired(
inputs: &[PlaylistInput<'_>],
desired: &[Desired],
) -> Vec<PlaylistDesired> {
let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
inputs
.iter()
.map(|input| {
let entries: Vec<M3u8Entry<'_>> = input
.members
.iter()
.map(|member| match by_id.get(member.id.as_str()) {
Some(d) => M3u8Entry {
title: d.clip.title.as_str(),
duration_secs: d.clip.duration,
relative_path: d.path.as_str(),
},
None => M3u8Entry {
title: member.title.as_str(),
duration_secs: member.duration,
relative_path: "",
},
})
.collect();
let content = render_m3u8(input.name, &entries);
let hash = content_hash(&content);
let path = format!("{}.m3u8", sanitise_name(input.name));
PlaylistDesired {
id: input.id.to_owned(),
name: input.name.to_owned(),
path,
content,
hash,
}
})
.collect()
}
pub(crate) fn rel_to_string(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
pub fn album_desired(
desired: &[Desired],
animated_covers: bool,
raw_cover: bool,
webp: WebpEncodeSettings,
) -> Vec<AlbumDesired> {
let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
for d in desired {
groups
.entry(d.lineage.root_id.as_str())
.or_default()
.push(d);
}
groups
.into_iter()
.map(|(root_id, members)| {
let album_dir = album_dir_of(&members);
let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
kind: ArtifactKind::FolderJpg,
path: album_child(&album_dir, "folder.jpg"),
source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
hash: art_hash(&source.clip),
content: None,
});
let folder_webp = animated_covers
.then(|| folder_webp_source(&members))
.flatten()
.map(|source| DesiredArtifact {
kind: ArtifactKind::FolderWebp,
path: album_child(&album_dir, "cover.webp"),
source_url: source.clip.video_cover_url.clone(),
hash: webp_art_hash(&source.clip.video_cover_url, &webp),
content: None,
});
let folder_mp4 = raw_cover
.then(|| folder_webp_source(&members))
.flatten()
.map(|source| DesiredArtifact {
kind: ArtifactKind::FolderMp4,
path: album_child(&album_dir, "cover.mp4"),
source_url: source.clip.video_cover_url.clone(),
hash: art_url_hash(&source.clip.video_cover_url),
content: None,
});
AlbumDesired {
root_id: root_id.to_owned(),
folder_jpg,
folder_webp,
folder_mp4,
}
})
.collect()
}
fn album_dir_of(members: &[&Desired]) -> String {
members
.iter()
.map(|d| parent_dir(&d.path))
.min()
.unwrap_or("")
.to_owned()
}
fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
members
.iter()
.copied()
.filter(|d| {
d.clip
.selected_image_url()
.is_some_and(|url| !url.is_empty())
})
.min_by(|a, b| {
b.clip
.play_count
.cmp(&a.clip.play_count)
.then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
.then_with(|| a.clip.id.cmp(&b.clip.id))
})
}
fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
members
.iter()
.copied()
.filter(|d| !d.clip.video_cover_url.is_empty())
.min_by(|a, b| {
a.clip
.created_at
.cmp(&b.clip.created_at)
.then_with(|| a.clip.id.cmp(&b.clip.id))
})
}
fn parent_dir(path: &str) -> &str {
match path.rsplit_once('/') {
Some((dir, _)) => dir,
None => "",
}
}
fn album_child(album_dir: &str, name: &str) -> String {
if album_dir.is_empty() {
name.to_owned()
} else {
format!("{album_dir}/{name}")
}
}
#[cfg(test)]
mod tests;