use std::path::Path;
use std::time::Duration;
use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use id3::TagLike;
use crate::api::types::{AlignedWord, Clip};
use crate::errors::CliError;
pub async fn download_clip(clip: &Clip, output_dir: &str, video: bool) -> Result<String, CliError> {
let url = if video {
clip.video_url
.as_deref()
.ok_or_else(|| CliError::Download("no video URL available".into()))?
} else {
clip.audio_url
.as_deref()
.ok_or_else(|| CliError::Download("no audio URL available".into()))?
};
let ext = if video { "mp4" } else { "mp3" };
let filename = clip_filename(&clip.title, &clip.id, ext);
tokio::fs::create_dir_all(output_dir).await?;
let path = Path::new(output_dir).join(&filename);
let part_path = path.with_extension(format!("{ext}.part"));
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(20))
.read_timeout(Duration::from_secs(60))
.timeout(Duration::from_secs(600))
.build()
.map_err(CliError::Http)?;
let resp = client
.get(url)
.send()
.await
.map_err(CliError::Http)?
.error_for_status()
.map_err(CliError::Http)?;
let total = resp.content_length().unwrap_or(0);
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::default_bar()
.template("{msg} [{bar:40}] {bytes}/{total_bytes} ({eta})")
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("=> "),
);
pb.set_message(filename.clone());
let written = match stream_to_file(&part_path, resp, &pb).await {
Ok(n) => n,
Err(e) => {
let _ = tokio::fs::remove_file(&part_path).await;
return Err(e);
}
};
if total > 0 && written != total {
let _ = tokio::fs::remove_file(&part_path).await;
return Err(CliError::Download(format!(
"incomplete download: received {written} of {total} bytes for {filename}"
)));
}
if let Err(e) = tokio::fs::rename(&part_path, &path).await {
let _ = tokio::fs::remove_file(&part_path).await;
return Err(e.into());
}
pb.finish_with_message("done");
Ok(path.display().to_string())
}
async fn stream_to_file(
part_path: &Path,
resp: reqwest::Response,
pb: &ProgressBar,
) -> Result<u64, CliError> {
use tokio::io::AsyncWriteExt as _;
let mut file = tokio::fs::File::create(part_path).await?;
let mut stream = resp.bytes_stream();
let mut written: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(CliError::Http)?;
pb.inc(chunk.len() as u64);
written += chunk.len() as u64;
file.write_all(&chunk).await?;
}
file.flush().await?;
Ok(written)
}
fn clip_filename(title: &str, id: &str, ext: &str) -> String {
let mut slug = String::new();
for c in title.to_lowercase().chars() {
if c.is_alphanumeric() {
slug.push(c);
} else if !slug.is_empty() && !slug.ends_with('-') {
slug.push('-');
}
}
if slug.ends_with('-') {
slug.pop();
}
let short_id = &id[..8.min(id.len())];
if slug.is_empty() {
format!("{short_id}.{ext}")
} else {
format!("{slug}-{short_id}.{ext}")
}
}
pub fn embed_lyrics_in_mp3(
mp3_path: &str,
title: &str,
plain_lyrics: Option<&str>,
aligned_words: Option<&[AlignedWord]>,
) -> Result<(), CliError> {
let mut tag = id3::Tag::read_from_path(mp3_path).unwrap_or_else(|_| id3::Tag::new());
tag.set_title(title);
if let Some(lyrics) = plain_lyrics {
tag.add_frame(id3::frame::Lyrics {
lang: "eng".to_string(),
description: String::new(),
text: lyrics.to_string(),
});
}
if let Some(words) = aligned_words {
let content: Vec<(u32, String)> = words
.iter()
.filter(|w| w.success)
.map(|w| ((w.start_s * 1000.0) as u32, w.word.clone()))
.collect();
if !content.is_empty() {
tag.add_frame(id3::frame::SynchronisedLyrics {
lang: "eng".to_string(),
timestamp_format: id3::frame::TimestampFormat::Ms,
content_type: id3::frame::SynchronisedLyricsType::Lyrics,
description: String::new(),
content,
});
}
}
tag.write_to_path(mp3_path, id3::Version::Id3v24)
.map_err(|e| CliError::Download(format!("failed to write ID3 tags: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_collapses_separator_runs() {
assert_eq!(
clip_filename("Hello, World!", "0123456789abcdef", "mp3"),
"hello-world-01234567.mp3"
);
assert_eq!(
clip_filename("a — b", "0123456789abcdef", "mp3"),
"a-b-01234567.mp3"
);
}
#[test]
fn filename_handles_empty_and_symbol_only_titles() {
assert_eq!(clip_filename("", "0123456789abcdef", "mp3"), "01234567.mp3");
assert_eq!(
clip_filename("!!!", "0123456789abcdef", "mp4"),
"01234567.mp4"
);
}
#[test]
fn filename_keeps_unicode_titles() {
assert_eq!(
clip_filename("夜の歌 Remix", "0123456789abcdef", "mp3"),
"夜の歌-remix-01234567.mp3"
);
assert_eq!(clip_filename("short", "abc", "mp3"), "short-abc.mp3");
}
}