use std::collections::BTreeSet;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use aisling::{Loader, LoaderConfig, LoaderKind, LoaderProgress};
use crossterm::{
cursor, event,
event::{Event, KeyCode, KeyEventKind},
execute,
terminal::{Clear, ClearType},
};
use soundforge::{
ArchiveOptions, ArchiveProgress, ArchiveProgressPhase, COMMONS_REUSE_URL, SoundForge,
SoundSummary, SoundTarget,
};
use crate::cli::{AudioArchiveArgs, Cli};
use crate::constants::THESA_METADATA_DIR;
use crate::error::{Result, ThesaError};
use crate::manifest::{prepare_output_dir, write_archive_manifest};
use crate::selection::parse_indices;
use crate::terminal::{
RankedPickerEntry, TerminalSession, draw_full_frame, pick_ranked_results_tui, read_input_line,
};
use crate::types::{AudioKind, Mode};
pub(crate) fn parse_audio_target(input: &str) -> Result<SoundTarget> {
soundforge::parse_sound_target(input)
.map_err(|error| ThesaError::InvalidTarget(error.to_string()))
}
pub(crate) fn run_audio_mode(
args: &mut Cli,
terminal_session: &mut Option<TerminalSession>,
) -> Result<()> {
let target_text = args.target.as_deref().unwrap_or_default().to_string();
let target = parse_audio_target(&target_text)?;
if args.output == Path::new("./archives") {
args.output = PathBuf::from("./archives/audio");
}
let forge = soundforge_client(args.audio_limit)?;
let discovered = if let Some(session) = terminal_session.as_mut() {
discover_audio_tui(
&mut session.terminal,
forge.clone(),
target.clone(),
args.audio_kind,
&target_text,
)?
} else {
forge
.discover(&target, args.audio_kind.into())
.map_err(|error| soundforge_error("discover", error))?
};
let discovered_count = discovered.len();
let mut selected = filtered_sounds(discovered, args.filter.as_deref());
let used_tui = terminal_session.is_some();
let mut selection_confirmed = false;
if target.is_collection() && selected.len() > 1 {
if let Some(session) = terminal_session.as_mut() {
selected = pick_audio_tui(&mut session.terminal, &selected)?;
selection_confirmed = true;
}
}
*terminal_session = None;
run_soundforge_archive(
&forge,
&target_text,
&target,
args.audio_kind,
&args.output,
args.skip_existing,
args.audio_max_download_bytes,
args.dry_run,
!used_tui && io::stdout().is_terminal(),
discovered_count,
selected,
selection_confirmed,
)
}
pub(crate) fn run_soundforge_archive_command(args: &AudioArchiveArgs, dry_run: bool) -> Result<()> {
let target = parse_audio_target(&args.target)?;
let forge = soundforge_client(args.limit)?;
let discovered = forge
.discover(&target, args.kind.into())
.map_err(|error| soundforge_error("discover", error))?;
let discovered_count = discovered.len();
let selected = filtered_sounds(discovered, args.filter.as_deref());
run_soundforge_archive(
&forge,
&args.target,
&target,
args.kind,
&args.archive_root,
args.skip_existing,
args.max_download_bytes,
dry_run || args.dry_run,
io::stdout().is_terminal(),
discovered_count,
selected,
false,
)
}
#[allow(clippy::too_many_arguments)]
fn run_soundforge_archive(
forge: &SoundForge,
target_text: &str,
target: &SoundTarget,
kind: AudioKind,
output: &Path,
skip_existing: bool,
max_download_bytes: u64,
dry_run: bool,
interactive: bool,
discovered_count: usize,
mut selected: Vec<SoundSummary>,
selection_confirmed: bool,
) -> Result<()> {
if selected.is_empty() {
if selection_confirmed {
println!("No reusable audio selected; nothing to archive.");
} else {
println!("No reusable audio matched your request.");
}
return Ok(());
}
if dry_run {
for sound in &selected {
enforce_download_limit(&sound.id, sound.file.size, max_download_bytes)?;
}
print_audio_plan(
target_text,
kind,
output,
skip_existing,
max_download_bytes,
discovered_count,
&selected,
);
return Ok(());
}
if interactive && target.is_collection() && selected.len() > 1 {
selected = pick_audio_interactively(&selected)?;
}
let output = prepare_output_dir(output)?;
println!(
"Archiving {} reusable sound(s) with SoundForge to {}",
selected.len(),
output.display()
);
println!("Source: Wikimedia Commons | Kind: {kind}");
println!("Commons reuse guidance: {COMMONS_REUSE_URL}");
print_attribution_warning(&selected);
println!("Selected audio:");
for sound in &selected {
print_sound_line(sound);
}
io::stdout().flush()?;
let options = ArchiveOptions {
output: output.clone(),
skip_existing,
max_download_bytes,
};
let mut renderer = io::stderr().is_terminal().then(AudioProgressRenderer::new);
let mut last_plain_completed = 0usize;
let mut last_plain_bytes = 0u64;
let summary = forge
.archive_with_progress(&selected, &options, |snapshot| {
if let Some(renderer) = renderer.as_mut() {
renderer.render(&snapshot);
} else {
let completed_changed = snapshot.completed_sounds != last_plain_completed;
let bytes_advanced = snapshot.bytes_written < last_plain_bytes
|| snapshot.bytes_written.saturating_sub(last_plain_bytes) >= 8 * 1024 * 1024;
if completed_changed || bytes_advanced {
print_plain_progress(&snapshot);
last_plain_completed = snapshot.completed_sounds;
last_plain_bytes = snapshot.bytes_written;
}
}
})
.map_err(|error| soundforge_error("archive", error))?;
if let Some(renderer) = renderer.as_mut() {
renderer.finish();
}
let selected_ids = selected
.iter()
.map(|sound| sound.id.as_str())
.collect::<Vec<_>>();
let archived_ids = summary
.sounds
.iter()
.map(|sound| sound.sound_id.as_str())
.collect::<Vec<_>>();
let failed_ids = summary
.failed
.iter()
.map(|failure| failure.sound_id.as_str())
.collect::<Vec<_>>();
let complete_ids = complete_audio_manifest_ids(
&selected_ids,
&archived_ids,
&failed_ids,
summary.selected,
summary.archived,
summary.skipped,
)?;
let mut manifest_count = 0usize;
let mut manifest_file_count = 0usize;
let mut manifest_total_bytes = 0u64;
for sound in &selected {
if !complete_ids.contains(&sound.id) {
continue;
}
let sound_output = output.join(&sound.id);
if !sound_output.is_dir() {
continue;
}
let manifest = write_archive_manifest(
&sound_output,
&format!("audio:{}", sound.provider.slug()),
&sound.id,
true,
)?;
manifest_count += 1;
manifest_file_count += manifest.archive.file_count;
manifest_total_bytes = manifest_total_bytes.saturating_add(manifest.archive.total_bytes);
}
println!(
"\nCompleted: {} sounds archived, {} skipped, {} failed",
summary.archived,
summary.skipped,
summary.failed.len()
);
println!("Audio: {} written", format_bytes(summary.bytes_written));
println!(
"Manifests: {manifest_count} sound-local {THESA_METADATA_DIR}/manifest.json sidecars ({manifest_file_count} files, {manifest_total_bytes} bytes)"
);
if !summary.failed.is_empty() {
eprintln!("Failures:");
for failure in summary.failed {
eprintln!(" - {}: {}", failure.sound_id, failure.message);
}
return Err(ThesaError::Message(
"some reusable audio downloads failed".to_string(),
));
}
Ok(())
}
fn soundforge_client(discovery_limit: usize) -> Result<SoundForge> {
SoundForge::with_user_agent(concat!(
"thesa/",
env!("CARGO_PKG_VERSION"),
" (SoundForge Wikimedia Commons archiver; https://github.com/Tknott95/Thesa)"
))
.map(|forge| forge.with_discovery_limit(discovery_limit))
.map_err(|error| soundforge_error("init", error))
}
fn discover_audio_tui(
terminal: &mut scrin::Terminal,
forge: SoundForge,
target: SoundTarget,
kind: AudioKind,
target_text: &str,
) -> Result<Vec<SoundSummary>> {
let loading_label = format!(
"discovering {} audio for {target_text} on Wikimedia Commons",
kind
);
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = forge.discover(&target, kind.into());
let _ = tx.send(result);
});
let mut tick = 0usize;
loop {
if let Ok(result) = rx.try_recv() {
return result.map_err(|error| soundforge_error("discover", error));
}
draw_full_frame(terminal, |frame| {
crate::draw_loading_tui(frame, &loading_label, tick, Mode::Audio, None)
})?;
tick = tick.wrapping_add(1);
if event::poll(Duration::from_millis(80))? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Press && key.code == KeyCode::Esc {
return Err(ThesaError::Message("aborted by user".to_string()));
}
}
}
}
fn print_audio_plan(
target: &str,
kind: AudioKind,
output: &Path,
skip_existing: bool,
max_download_bytes: u64,
discovered_count: usize,
sounds: &[SoundSummary],
) {
println!(
"Dry run: {} of {} reusable sounds selected as archive candidates with SoundForge",
sounds.len(),
discovered_count
);
println!("Provider: Wikimedia Commons");
println!("Kind: {kind}");
println!("Target: {target}");
println!("Output: {}", output.display());
print_dry_run_skip_notice(skip_existing);
println!("Maximum audio bytes: {max_download_bytes}");
println!("Commons reuse guidance: {COMMONS_REUSE_URL}");
print_attribution_warning(sounds);
for sound in sounds {
print_sound_line(sound);
}
}
fn print_dry_run_skip_notice(skip_existing: bool) {
if skip_existing {
println!(
"Skip existing: true (manifest and checksum verification occurs during archive; candidates may be skipped)"
);
} else {
println!("Skip existing: false");
}
}
fn complete_audio_manifest_ids(
selected_ids: &[&str],
archived_ids: &[&str],
failed_ids: &[&str],
selected_count: usize,
archived_count: usize,
skipped_count: usize,
) -> Result<BTreeSet<String>> {
let selected = selected_ids
.iter()
.map(|id| (*id).to_string())
.collect::<BTreeSet<_>>();
let archived = archived_ids
.iter()
.map(|id| (*id).to_string())
.collect::<BTreeSet<_>>();
let failed = failed_ids
.iter()
.map(|id| (*id).to_string())
.collect::<BTreeSet<_>>();
let counts_match = selected_count == selected_ids.len()
&& selected.len() == selected_ids.len()
&& archived_count == archived_ids.len()
&& archived.len() == archived_ids.len()
&& failed.len() == failed_ids.len()
&& selected_count == archived_count + skipped_count + failed_ids.len();
if !counts_match
|| !archived.is_subset(&selected)
|| !failed.is_subset(&selected)
|| !archived.is_disjoint(&failed)
{
return Err(ThesaError::Message(
"SoundForge returned an inconsistent archive summary; no Thesa audio manifests were written"
.to_string(),
));
}
let complete = selected
.difference(&failed)
.cloned()
.collect::<BTreeSet<_>>();
if complete.len() != archived_count + skipped_count {
return Err(ThesaError::Message(
"SoundForge completion records did not match archived and verified-skipped audio"
.to_string(),
));
}
Ok(complete)
}
fn pick_audio_interactively(sounds: &[SoundSummary]) -> Result<Vec<SoundSummary>> {
println!("Discovered {} ranked reusable sounds:", sounds.len());
for (index, sound) in sounds.iter().enumerate() {
print!(" {:>3})", index + 1);
print_sound_line(sound);
}
println!("Select sound indexes such as 1,3,5-8, or press Enter for all.");
print!("Selection: ");
io::stdout().flush()?;
let input = read_input_line(&mut io::stdin().lock())?;
let input = input.trim();
if input.is_empty() || input.eq_ignore_ascii_case("all") {
return Ok(sounds.to_vec());
}
let indexes = parse_indices(input, sounds.len()).map_err(ThesaError::Message)?;
Ok(indexes
.into_iter()
.map(|index| sounds[index].clone())
.collect())
}
fn filtered_sounds(sounds: Vec<SoundSummary>, filter: Option<&str>) -> Vec<SoundSummary> {
sounds
.into_iter()
.filter(|sound| sound_matches_filter(sound, filter))
.collect()
}
fn pick_audio_tui(
terminal: &mut scrin::Terminal,
sounds: &[SoundSummary],
) -> Result<Vec<SoundSummary>> {
let entries = sounds
.iter()
.map(|sound| RankedPickerEntry {
provider: sound.provider.label().to_string(),
title: sound.title.clone(),
license: sound.rights.license.clone(),
metrics: format!(
"{} | {} | {} uses",
format_duration(sound.file.duration_ms),
format_bytes(sound.file.size),
sound.usage_count
),
details: vec![
("id".to_string(), sound.id.clone()),
("attribution".to_string(), sound.rights.attribution.clone()),
("mime".to_string(), sound.file.mime.clone()),
],
})
.collect::<Vec<_>>();
let indexes = pick_ranked_results_tui(terminal, "audio picker", "sounds", &entries)?;
Ok(indexes
.into_iter()
.map(|index| sounds[index].clone())
.collect())
}
fn print_sound_line(sound: &SoundSummary) {
let attribution = if sound.rights.attribution_required {
"attribution required"
} else {
"attribution retained"
};
println!(
" - [{}] {} ({}, {}, {} uses, {}, {attribution}) | {}",
sound.provider.label(),
sound.title,
format_duration(sound.file.duration_ms),
format_bytes(sound.file.size),
sound.usage_count,
sound.rights.license,
sound.source_url
);
}
fn print_attribution_warning(sounds: &[SoundSummary]) {
if sounds.iter().any(|sound| sound.rights.attribution_required) {
println!(
"Attribution warning: one or more licenses require attribution; retain the SoundForge manifest and review each Commons source page."
);
} else {
println!(
"Attribution notice: creator and Commons source metadata is retained even when attribution is not required."
);
}
}
fn sound_matches_filter(sound: &SoundSummary, filter: Option<&str>) -> bool {
let Some(filter) = filter.map(str::trim).filter(|filter| !filter.is_empty()) else {
return true;
};
let filter = filter.to_ascii_lowercase();
sound.id.to_ascii_lowercase().contains(&filter)
|| sound.title.to_ascii_lowercase().contains(&filter)
|| sound.description.to_ascii_lowercase().contains(&filter)
|| sound.rights.license.to_ascii_lowercase().contains(&filter)
|| sound
.categories
.iter()
.any(|category| category.to_ascii_lowercase().contains(&filter))
}
fn soundforge_error(context: &str, error: impl std::fmt::Display) -> ThesaError {
ThesaError::Message(format!("soundforge {context} failed: {error}"))
}
fn enforce_download_limit(sound_id: &str, size: u64, max_download_bytes: u64) -> Result<()> {
if max_download_bytes > 0 && size > max_download_bytes {
Err(ThesaError::Message(format!(
"soundforge dry run rejected '{sound_id}': audio is {size} bytes, exceeding the {max_download_bytes} byte limit"
)))
} else {
Ok(())
}
}
struct AudioProgressRenderer {
stderr: io::Stderr,
loader: Loader,
tick: usize,
wrote: bool,
}
impl AudioProgressRenderer {
fn new() -> Self {
let mut stderr = io::stderr();
let _ = execute!(stderr, cursor::Hide);
Self {
stderr,
loader: Loader::with_config(
LoaderKind::Tqdm,
LoaderConfig::default()
.with_width(28)
.with_label("soundforge")
.with_unit("sounds")
.with_fraction(true),
),
tick: 0,
wrote: false,
}
}
fn render(&mut self, progress: &ArchiveProgress) {
let total = progress.total_sounds.max(1) as u64;
let current = progress.completed_sounds.min(progress.total_sounds) as u64;
let loader = self
.loader
.frame(self.tick, LoaderProgress::from_counts(current, total))
.to_ansi_string();
let active_sound = progress.active_sound.as_deref().unwrap_or("soundforge");
let active_file = progress.active_file.as_deref().unwrap_or("finalizing");
let line = format!(
"{loader} | {} | {} / {}",
format_active_bytes(progress),
truncate(active_sound, 32),
truncate(active_file, 28)
);
let _ = execute!(
self.stderr,
cursor::MoveToColumn(0),
Clear(ClearType::CurrentLine)
);
let _ = write!(self.stderr, "{line}");
let _ = self.stderr.flush();
self.tick = self.tick.wrapping_add(1);
self.wrote = true;
}
fn finish(&mut self) {
if self.wrote {
let _ = writeln!(self.stderr);
}
let _ = execute!(self.stderr, cursor::Show);
let _ = self.stderr.flush();
}
}
impl Drop for AudioProgressRenderer {
fn drop(&mut self) {
let _ = execute!(self.stderr, cursor::Show);
}
}
fn print_plain_progress(progress: &ArchiveProgress) {
if progress.phase == ArchiveProgressPhase::Starting {
return;
}
if let (Some(sound), Some(file)) = (&progress.active_sound, &progress.active_file) {
println!(
"SoundForge downloading {}/{}: {}",
truncate(sound, 64),
truncate(file, 48),
format_active_bytes(progress)
);
return;
}
let skipped = progress
.completed_sounds
.saturating_sub(progress.succeeded_sounds + progress.failed_sounds);
println!(
"SoundForge {}/{} sounds (ok={}, skipped={}, failed={}, bytes={})",
progress.completed_sounds,
progress.total_sounds,
progress.succeeded_sounds,
skipped,
progress.failed_sounds,
format_bytes(progress.bytes_written)
);
}
fn format_active_bytes(progress: &ArchiveProgress) -> String {
match progress.active_total_bytes {
Some(total) => format!(
"{}/{}",
format_bytes(progress.active_bytes_written),
format_bytes(total)
),
None => format_bytes(progress.bytes_written),
}
}
fn format_duration(duration_ms: u64) -> String {
let total_seconds = duration_ms / 1000;
let minutes = total_seconds / 60;
let seconds = total_seconds % 60;
format!("{minutes}:{seconds:02}")
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
fn truncate(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
value.to_string()
} else {
let keep = max_chars.saturating_sub(3);
format!("{}...", value.chars().take(keep).collect::<String>())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_soundforge_targets() {
assert_eq!(
parse_audio_target("search:laser").unwrap(),
SoundTarget::Search("laser".to_string())
);
assert_eq!(
parse_audio_target("category:Sound effects").unwrap(),
SoundTarget::Category("Sound effects".to_string())
);
assert!(parse_audio_target("").is_err());
}
#[test]
fn formats_audio_metadata() {
assert_eq!(format_duration(0), "0:00");
assert_eq!(format_duration(65_999), "1:05");
assert_eq!(format_bytes(1024), "1.00 KiB");
assert!(enforce_download_limit("1", 2, 1).is_err());
assert!(enforce_download_limit("1", 2, 0).is_ok());
}
#[test]
fn failed_sound_with_old_destination_is_not_manifest_eligible() {
let complete = complete_audio_manifest_ids(
&["fresh", "old-intact", "verified-skip"],
&["fresh"],
&["old-intact"],
3,
1,
1,
)
.unwrap();
assert_eq!(
complete,
BTreeSet::from(["fresh".to_string(), "verified-skip".to_string()])
);
assert!(!complete.contains("old-intact"));
}
#[test]
fn inconsistent_audio_summary_blocks_all_manifest_writes() {
assert!(
complete_audio_manifest_ids(&["one"], &[], &[], 1, 0, 0).is_err(),
"an unclassified result must not receive a complete sidecar"
);
}
}