use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::discover::{
build_exclude_set, classify_media_sidecar_status, discover_and_report_media,
};
use crate::BackendType;
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct TranscribeManifest {
pub completed: Vec<String>,
pub failed: Vec<String>,
}
impl TranscribeManifest {
pub(crate) fn load(root: &Path) -> Self {
let manifest_path = root.join(".transcribe-manifest.json");
fs::read_to_string(manifest_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
#[allow(dead_code)]
pub(crate) fn save(&self, root: &Path) -> Result<()> {
let manifest_path = root.join(".transcribe-manifest.json");
let json = serde_json::to_string_pretty(self)?;
fs::write(manifest_path, json)?;
Ok(())
}
}
pub(crate) fn filter_files_for_transcription(
media_files: &[PathBuf],
root: &Path,
skip_existing: bool,
) -> Vec<PathBuf> {
let (has_sidecar, needs_transcription) = classify_media_sidecar_status(media_files);
let manifest = TranscribeManifest::load(root);
let previously_completed = manifest.completed.len();
let to_process: Vec<PathBuf> = if skip_existing {
needs_transcription
.into_iter()
.filter(|f| {
!manifest
.completed
.contains(&f.to_string_lossy().to_string())
})
.collect()
} else {
media_files
.iter()
.filter(|f| {
!manifest
.completed
.contains(&f.to_string_lossy().to_string())
})
.cloned()
.collect()
};
println!(
"\nSidecar status: {} with .srt/.vtt, {} need transcription",
has_sidecar.len(),
to_process.len()
);
if previously_completed > 0 {
println!(
" {} previously completed (from manifest)",
previously_completed
);
}
to_process
}
#[allow(clippy::unnecessary_wraps)]
fn run_transcription_or_report(
to_process: &[PathBuf],
jobs: usize,
model: Option<&str>,
backend: BackendType,
root: &Path,
prompt: Option<&str>,
hotwords: &[String],
) -> Result<()> {
let _ = (jobs, model, backend, root, prompt, hotwords);
println!(
"\nTranscription is not available in this build: it lives in the\n\
standalone whisper.apr project, not in aprender.\n\n\
{} files need transcription. Run with --dry-run to list them.",
to_process.len()
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_transcribe(
path: &str,
recursive: bool,
skip_existing: bool,
jobs: usize,
model: Option<&str>,
backend: BackendType,
dry_run: bool,
prompt: Option<&str>,
hotwords_file: Option<&str>,
exclude_patterns: &[String],
) -> Result<()> {
let root = Path::new(path);
if !root.exists() {
anyhow::bail!("Path not found: {}", root.display());
}
let exclude = build_exclude_set(exclude_patterns)?;
let start_time = std::time::Instant::now();
let media_files = discover_and_report_media(root, recursive, &exclude)?;
if media_files.is_empty() {
return Ok(());
}
let to_process = filter_files_for_transcription(&media_files, root, skip_existing);
if to_process.is_empty() {
println!("All files already have sidecars. Nothing to do.");
return Ok(());
}
if dry_run {
println!("\nDry run — files that would be transcribed:");
for file in &to_process {
println!(" {}", file.display());
}
println!("\nTotal: {} files", to_process.len());
return Ok(());
}
let hotwords: Vec<String> = hotwords_file
.map(|path| {
fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect()
})
.unwrap_or_default();
if !hotwords.is_empty() {
println!("Loaded {} hotwords for vocabulary biasing", hotwords.len());
}
if let Some(p) = prompt {
println!("Using prompt: {:?}", p);
}
run_transcription_or_report(&to_process, jobs, model, backend, root, prompt, &hotwords)?;
let elapsed = start_time.elapsed();
println!(
"\nTotal time: {:.1}s ({:.1} files/sec)",
elapsed.as_secs_f64(),
media_files.len() as f64 / elapsed.as_secs_f64().max(0.001)
);
Ok(())
}