use std::collections::{HashSet, VecDeque};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Semaphore, mpsc};
use tokio::task::JoinSet;
use tracing::Instrument;
use super::format::{InputFileType, VideoQuality};
use super::manifest::{self, PendingFile};
use super::{destination, media, placement, push};
use crate::core::rclone::RcloneCommand as _;
use crate::core::rclone::command::Copy;
const TRANSCODE_RETRIES: usize = 2;
const TRANSCODE_RETRY_BACKOFF: Duration = Duration::from_secs(1);
const CIRCUIT_BREAKER_SAMPLE_SIZE: usize = 20;
const CIRCUIT_BREAKER_TRIP_THRESHOLD: usize = 18;
fn circuit_breaker_should_trip(samples: usize, matches: usize) -> bool {
samples == CIRCUIT_BREAKER_SAMPLE_SIZE && matches >= CIRCUIT_BREAKER_TRIP_THRESHOLD
}
async fn retry_transcode_unless_fatal<T, F, Fut>(
attempts: usize,
backoff: Duration,
context: &str,
mut f: F,
) -> Result<T, String>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, String>>,
{
let mut last_err = None;
for attempt in 0..attempts.max(1) {
if attempt > 0 {
tokio::time::sleep(backoff * attempt as u32).await;
}
match f().await {
Ok(value) => return Ok(value),
Err(err) => {
if media::is_non_retryable_transcode_error(&err) {
tracing::warn!(
relative_path = %context,
attempt = attempt + 1,
attempts,
error = %err,
"transcode failed with a non-retryable error; skipping remaining retries"
);
return Err(err);
}
tracing::warn!(
relative_path = %context,
attempt = attempt + 1,
attempts,
error = %err,
"retrying after error"
);
last_err = Some(err);
}
}
}
if let Some(err) = &last_err {
tracing::error!(
relative_path = %context,
attempts,
error = %err,
"retries exhausted"
);
}
Err(last_err
.unwrap_or_else(|| "retry_transcode_unless_fatal called with zero attempts".to_string()))
}
pub(crate) struct TransformPlan {
pub source_path: String,
pub destination_path: String,
pub local_output: PathBuf,
pub input_file_type: InputFileType,
pub video_quality: VideoQuality,
pub download_transfers: usize,
pub download_checkers: usize,
pub tpslimit: Option<usize>,
pub max_pending_downloads: usize,
pub run_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Outcome {
Transcoded,
CopiedThrough,
Failed,
}
impl Outcome {
pub(crate) fn as_str(self) -> &'static str {
match self {
Outcome::Transcoded => "transcoded",
Outcome::CopiedThrough => "copied_through",
Outcome::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FailureStage {
Pull,
Transcode,
Place,
Push,
}
impl FailureStage {
pub(crate) fn as_str(self) -> &'static str {
match self {
FailureStage::Pull => "pull",
FailureStage::Transcode => "transcode",
FailureStage::Place => "place",
FailureStage::Push => "push",
}
}
}
type ProcessResult = Result<PathBuf, (FailureStage, String)>;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FailureBreakdown {
pub pull: u64,
pub transcode: u64,
pub place: u64,
pub push: u64,
}
impl FailureBreakdown {
fn record(&mut self, stage: FailureStage) {
match stage {
FailureStage::Pull => self.pull += 1,
FailureStage::Transcode => self.transcode += 1,
FailureStage::Place => self.place += 1,
FailureStage::Push => self.push += 1,
}
}
pub(crate) fn total(self) -> u64 {
self.pull + self.transcode + self.place + self.push
}
}
#[derive(Debug, Clone)]
pub(crate) struct FileOutcome {
pub relative_path: String,
pub destination_filename: Option<String>,
pub outcome: Outcome,
pub stage: Option<FailureStage>,
pub detail: String,
}
#[derive(Debug, Default)]
pub(crate) struct TransformSummary {
pub outcomes: Vec<FileOutcome>,
pub pulled: u64,
pub pushed: u64,
pub failed: bool,
pub failures: FailureBreakdown,
pub skipped_already_done: u64,
pub skipped_apple_double: u64,
pub pull_errors: u64,
}
fn source_dir(local_output: &Path) -> PathBuf {
local_output.join("source")
}
fn staging_dir(local_output: &Path) -> PathBuf {
local_output.join(".staging")
}
fn scratch_dir(local_output: &Path) -> PathBuf {
staging_dir(local_output).join("scratch")
}
fn result_dir(local_output: &Path) -> PathBuf {
local_output.join("result")
}
fn push_log_dir(local_output: &Path) -> PathBuf {
staging_dir(local_output).join("push-logs")
}
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(
skip_all,
fields(relative_path = %pending_file.relative_path),
level = "debug"
)]
async fn process_one(
pending_file: &PendingFile,
input_file_type: InputFileType,
video_quality: VideoQuality,
transcode_threads: usize,
source_path: &str,
destination_path: &str,
result_dir: &Path,
scratch_dir: &Path,
push_log_dir: &Path,
push_semaphore: Arc<Semaphore>,
) -> ProcessResult {
let output_extension = input_file_type.output_extension();
let destination_name = placement::compute_destination_name(
source_path,
&pending_file.relative_path,
output_extension,
);
let existing_result_path = result_dir.join(&destination_name);
let final_path = if existing_result_path.exists() {
existing_result_path
} else {
let scratch_path = scratch_dir.join(format!("{destination_name}.scratch"));
std::fs::create_dir_all(scratch_dir).map_err(|err| {
(
FailureStage::Transcode,
format!("failed to create {}: {err}", scratch_dir.display()),
)
})?;
crate::commands::job::local::check_disk_space(scratch_dir, 0)
.map_err(|err| (FailureStage::Transcode, err))?;
let transcode_result = match input_file_type {
InputFileType::Jpeg => media::copy_through(&pending_file.absolute_path, &scratch_path),
InputFileType::Png | InputFileType::Heic => {
retry_transcode_unless_fatal(
TRANSCODE_RETRIES,
TRANSCODE_RETRY_BACKOFF,
&pending_file.relative_path,
|| media::transcode_to_jpg(&pending_file.absolute_path, &scratch_path),
)
.await
}
InputFileType::Mov | InputFileType::M4v | InputFileType::Mp4 => {
retry_transcode_unless_fatal(
TRANSCODE_RETRIES,
TRANSCODE_RETRY_BACKOFF,
&pending_file.relative_path,
|| {
media::transcode_video(
&pending_file.absolute_path,
&scratch_path,
video_quality,
transcode_threads,
)
},
)
.await
}
InputFileType::Mp4Audio => {
retry_transcode_unless_fatal(
TRANSCODE_RETRIES,
TRANSCODE_RETRY_BACKOFF,
&pending_file.relative_path,
|| media::transcode_audio(&pending_file.absolute_path, &scratch_path),
)
.await
}
};
crate::observability::metrics::record_phase_count(
"transform",
"transcode",
if transcode_result.is_ok() {
outcome_for(input_file_type).as_str()
} else {
Outcome::Failed.as_str()
},
1,
None,
);
if let Err(err) = &transcode_result
&& media::is_wrong_input_kind_error(err)
{
remove_local_copy(&pending_file.absolute_path);
}
transcode_result.map_err(|err| (FailureStage::Transcode, err))?;
placement::place_one(
result_dir,
&scratch_path,
source_path,
&pending_file.relative_path,
output_extension,
)
.map_err(|err| (FailureStage::Place, err))?
};
let destination_filename = final_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
(
FailureStage::Place,
format!(
"placed file at {} has no valid UTF-8 filename",
final_path.display()
),
)
})?
.to_string();
let _permit = push_semaphore.acquire_owned().await.map_err(|err| {
(
FailureStage::Push,
format!("push semaphore closed unexpectedly: {err}"),
)
})?;
push::push_one(
&final_path,
destination_path,
&destination_filename,
push_log_dir,
)
.await
.map_err(|err| (FailureStage::Push, err))?;
remove_local_copy(&pending_file.absolute_path);
remove_local_copy(&final_path);
Ok(final_path)
}
fn remove_local_copy(path: &Path) {
if let Err(err) = std::fs::remove_file(path) {
tracing::warn!(
path = %path.display(),
error = %err,
"transform: failed to remove local file"
);
}
}
fn outcome_for(input_file_type: InputFileType) -> Outcome {
match input_file_type {
InputFileType::Jpeg => Outcome::CopiedThrough,
InputFileType::Png
| InputFileType::Heic
| InputFileType::Mov
| InputFileType::M4v
| InputFileType::Mp4
| InputFileType::Mp4Audio => Outcome::Transcoded,
}
}
#[cfg(unix)]
fn pause_pull_process(pid: u32) {
if let Err(err) = unsafe {
if libc::kill(pid as i32, libc::SIGSTOP) != 0 {
Err("kill SIGSTOP failed")
} else {
Ok(())
}
} {
tracing::warn!(pid, error = ?err, "transform: failed to pause pull process");
} else {
tracing::debug!(pid, "transform: paused pull process");
}
}
#[cfg(unix)]
fn resume_pull_process(pid: u32) {
if let Err(err) = unsafe {
if libc::kill(pid as i32, libc::SIGCONT) != 0 {
Err("kill SIGCONT failed")
} else {
Ok(())
}
} {
tracing::warn!(pid, error = ?err, "transform: failed to resume pull process");
} else {
tracing::debug!(pid, "transform: resumed pull process");
}
}
fn enqueue(
relative_path: String,
source_dir: &Path,
source_path: &str,
output_extension: &str,
existing_destination_filenames: &HashSet<String>,
dispatched: &mut HashSet<String>,
queue: &mut VecDeque<PendingFile>,
) -> EnqueueOutcome {
if crate::core::data::is_apple_double_basename(&relative_path) {
return EnqueueOutcome::SkippedAppleDouble;
}
let destination_name =
placement::compute_destination_name(source_path, &relative_path, output_extension);
if existing_destination_filenames.contains(&destination_name) {
return EnqueueOutcome::AlreadyDone;
}
if !dispatched.insert(relative_path.clone()) {
return EnqueueOutcome::AlreadyDispatched;
}
let absolute_path = source_dir.join(&relative_path);
queue.push_back(PendingFile {
relative_path,
absolute_path,
});
EnqueueOutcome::Queued
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EnqueueOutcome {
Queued,
AlreadyDone,
AlreadyDispatched,
SkippedAppleDouble,
}
fn files_needing_pull(
source_relative_paths: Vec<String>,
source_path: &str,
output_extension: &str,
existing_destination_filenames: &HashSet<String>,
) -> PullSelection {
let mut selection = PullSelection::default();
for relative_path in source_relative_paths {
if crate::core::data::is_apple_double_basename(&relative_path) {
selection.skipped_apple_double += 1;
continue;
}
let destination_name =
placement::compute_destination_name(source_path, &relative_path, output_extension);
if existing_destination_filenames.contains(&destination_name) {
selection.skipped_already_done += 1;
continue;
}
selection.to_pull.push(relative_path);
}
selection
}
#[derive(Debug, Default)]
struct PullSelection {
to_pull: Vec<String>,
skipped_already_done: u64,
skipped_apple_double: u64,
}
fn write_files_from(staging_dir: &Path, relative_paths: &[String]) -> Result<PathBuf, String> {
std::fs::create_dir_all(staging_dir)
.map_err(|err| format!("failed to create {}: {err}", staging_dir.display()))?;
let path = staging_dir.join("pull-files-from.txt");
std::fs::write(&path, relative_paths.join("\n"))
.map_err(|err| format!("failed to write {}: {err}", path.display()))?;
Ok(path)
}
pub(crate) async fn run_transform_job(
plan: &TransformPlan,
concurrency: usize,
upload_concurrency: usize,
) -> Result<TransformSummary, String> {
let source_dir = source_dir(&plan.local_output);
let result_dir = result_dir(&plan.local_output);
let scratch_dir = scratch_dir(&plan.local_output);
let push_log_dir = push_log_dir(&plan.local_output);
if let Err(err) = std::fs::remove_dir_all(&scratch_dir)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
path = %scratch_dir.display(),
error = %err,
"transform: failed to clear stale scratch directory from a prior run"
);
}
let output_extension = plan.input_file_type.output_extension();
let existing_destination_filenames =
destination::list_existing_filenames(&plan.destination_path).await?;
let already_on_disk = manifest::gather_pending(&source_dir, plan.input_file_type)?;
let mut dispatched: HashSet<String> = HashSet::new();
let mut queue: VecDeque<PendingFile> = VecDeque::new();
let mut skipped_apple_double: u64 = 0;
for pending_file in already_on_disk {
if enqueue(
pending_file.relative_path,
&source_dir,
&plan.source_path,
output_extension,
&existing_destination_filenames,
&mut dispatched,
&mut queue,
) == EnqueueOutcome::SkippedAppleDouble
{
skipped_apple_double += 1;
}
}
let source_relative_paths = destination::list_source_relative_paths(
&plan.source_path,
plan.input_file_type.extensions(),
)
.await?;
if source_relative_paths.is_empty() {
let patterns = plan
.input_file_type
.extensions()
.iter()
.map(|extension| format!("*.{extension}"))
.collect::<Vec<_>>()
.join(", ");
eprintln!(
"WARNING: {} matched 0 files for --input-file-type {} (patterns: {patterns}). \
Nothing to do -- check the prefix and file type.",
plan.source_path, plan.input_file_type
);
tracing::warn!(
pigeon_job = "transform",
source = %plan.source_path,
input_file_type = %plan.input_file_type,
patterns = %patterns,
"transform: source listing matched no files"
);
}
let selection = files_needing_pull(
source_relative_paths,
&plan.source_path,
output_extension,
&existing_destination_filenames,
);
let files_to_pull = selection.to_pull;
let selection_skipped_already_done = selection.skipped_already_done;
skipped_apple_double += selection.skipped_apple_double;
if !files_to_pull.is_empty() {
std::fs::create_dir_all(&source_dir)
.map_err(|err| format!("failed to create {}: {err}", source_dir.display()))?;
crate::commands::job::local::check_disk_space(&source_dir, 0)?;
}
tracing::info!(
pigeon_job = "transform",
files_to_pull = files_to_pull.len(),
skipped_already_done = selection.skipped_already_done,
skipped_apple_double,
queued_from_local_scan = queue.len(),
"transform: pull phase starting"
);
let (copied_tx, mut copied_rx) = mpsc::unbounded_channel();
let (pull_pid_tx, mut pull_pid_rx) = tokio::sync::mpsc::channel::<u32>(1);
let pull_log_path = plan
.local_output
.join(format!("rclone-pull-{}.jsonl", plan.run_id));
let mut pull_handle = if files_to_pull.is_empty() {
tokio::spawn(async move {
drop(copied_tx);
Ok::<_, String>(crate::core::rclone::RcloneLogSummary::default())
})
} else {
let files_from_path = write_files_from(&staging_dir(&plan.local_output), &files_to_pull)?;
let pull_pid_tx_clone = pull_pid_tx.clone();
tokio::spawn({
let source_path = plan.source_path.clone();
let source_dir_str = source_dir.display().to_string();
let download_transfers = plan.download_transfers;
let download_checkers = plan.download_checkers;
let tpslimit = plan.tpslimit;
async move {
(Copy {
source: source_path,
destination: source_dir_str,
log_path: pull_log_path,
transfers: download_transfers,
checkers: download_checkers,
tpslimit,
files_from: Some(files_from_path),
job: "transform",
step: "pull",
upload_metrics: false,
hooks: crate::core::rclone::StreamHooks {
on_copied: Some(copied_tx),
pid_tx: Some(pull_pid_tx_clone),
},
})
.run()
.await
}
})
};
drop(pull_pid_tx);
let transcode_threads = (std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
/ concurrency.max(1))
.max(1);
let push_semaphore = Arc::new(Semaphore::new(upload_concurrency.max(1)));
let high_water_mark = plan.max_pending_downloads;
let low_water_mark = (high_water_mark / 2).max(1);
let mut pull_pid: Option<u32> = None;
let mut pull_is_paused = false;
let mut in_flight: JoinSet<(String, ProcessResult)> = JoinSet::new();
let mut outcomes = Vec::new();
let mut pushed: u64 = 0;
let mut pull_finished = false;
let mut copied_rx_closed = false;
let mut pull_result = None;
let mut failures = FailureBreakdown::default();
let mut circuit_breaker_samples: usize = 0;
let mut circuit_breaker_matches: usize = 0;
let mut circuit_breaker_tripped = false;
let mut circuit_breaker_example: Option<String> = None;
let command_span = tracing::Span::current();
loop {
while !circuit_breaker_tripped && in_flight.len() < concurrency.max(1) {
let Some(pending_file) = queue.pop_front() else {
break;
};
let relative_path = pending_file.relative_path.clone();
let source_path = plan.source_path.clone();
let destination_path = plan.destination_path.clone();
let result_dir = result_dir.clone();
let scratch_dir = scratch_dir.clone();
let push_log_dir = push_log_dir.clone();
let push_semaphore = push_semaphore.clone();
let input_file_type = plan.input_file_type;
let video_quality = plan.video_quality;
in_flight.spawn(
async move {
let outcome = process_one(
&pending_file,
input_file_type,
video_quality,
transcode_threads,
&source_path,
&destination_path,
&result_dir,
&scratch_dir,
&push_log_dir,
push_semaphore,
)
.await;
(relative_path, outcome)
}
.instrument(command_span.clone()),
);
}
if in_flight.is_empty() && pull_finished && (queue.is_empty() || circuit_breaker_tripped) {
break;
}
tokio::select! {
received_pid = pull_pid_rx.recv() => {
if let Some(pid) = received_pid {
pull_pid = Some(pid);
}
}
joined = in_flight.join_next(), if !in_flight.is_empty() => {
let Some(joined) = joined else { continue; };
let (relative_path, result) = joined.expect("transform pipeline task panicked");
if !circuit_breaker_tripped && circuit_breaker_samples < CIRCUIT_BREAKER_SAMPLE_SIZE {
circuit_breaker_samples += 1;
if let Err((stage, err)) = &result
&& *stage == FailureStage::Transcode
&& media::is_non_retryable_transcode_error(err)
{
circuit_breaker_matches += 1;
circuit_breaker_example.get_or_insert_with(|| err.clone());
}
if circuit_breaker_should_trip(
circuit_breaker_samples,
circuit_breaker_matches,
) {
circuit_breaker_tripped = true;
if let (Some(pid), true) = (pull_pid, pull_is_paused) {
resume_pull_process(pid);
pull_is_paused = false;
}
tracing::error!(
sample_size = CIRCUIT_BREAKER_SAMPLE_SIZE,
matches = circuit_breaker_matches,
"transform circuit breaker tripped: stopping dispatch of further files"
);
}
}
match result {
Ok(final_path) => {
pushed += 1;
outcomes.push(FileOutcome {
relative_path,
destination_filename: final_path
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string),
outcome: outcome_for(plan.input_file_type),
stage: None,
detail: String::new(),
});
}
Err((stage, err)) => {
failures.record(stage);
outcomes.push(FileOutcome {
relative_path,
destination_filename: None,
outcome: Outcome::Failed,
stage: Some(stage),
detail: err,
});
}
}
if let Some(pid) = pull_pid {
let backlog = queue.len() + in_flight.len();
if pull_is_paused && backlog <= low_water_mark {
resume_pull_process(pid);
pull_is_paused = false;
} else if !pull_is_paused && backlog >= high_water_mark {
pause_pull_process(pid);
pull_is_paused = true;
}
}
}
received = copied_rx.recv(), if !pull_finished && !copied_rx_closed => {
match received {
Some(relative_path) => {
if !circuit_breaker_tripped
&& enqueue(
relative_path,
&source_dir,
&plan.source_path,
output_extension,
&existing_destination_filenames,
&mut dispatched,
&mut queue,
) == EnqueueOutcome::SkippedAppleDouble
{
skipped_apple_double += 1;
}
}
None => copied_rx_closed = true,
}
}
joined = &mut pull_handle, if !pull_finished => {
let result = joined.map_err(|err| format!("rclone pull task join failed: {err}"))?;
pull_finished = true;
while let Ok(relative_path) = copied_rx.try_recv() {
if !circuit_breaker_tripped
&& enqueue(
relative_path,
&source_dir,
&plan.source_path,
output_extension,
&existing_destination_filenames,
&mut dispatched,
&mut queue,
) == EnqueueOutcome::SkippedAppleDouble
{
skipped_apple_double += 1;
}
}
pull_result = Some(result);
}
}
}
let pull_summary = pull_result.expect("loop only exits after pull_finished is set")?;
if circuit_breaker_tripped {
let example = circuit_breaker_example
.as_deref()
.unwrap_or("<no example captured>");
return Err(format!(
"transform circuit breaker tripped: at least {CIRCUIT_BREAKER_TRIP_THRESHOLD} of the \
first {CIRCUIT_BREAKER_SAMPLE_SIZE} completed files failed transcoding with the same \
deterministic error signature. That is a systemic problem, not isolated bad source \
files, so the run is being aborted instead of grinding through every remaining file \
with a guaranteed-identical failure. The two usual causes: (1) the wrong \
--input-file-type for what this source actually holds -- e.g. a mp4-audio run \
pointed at real video, or vice versa (ADR-0143); or (2) an environment/capability \
gap, the ffmpeg build on this job's host lacking support for this input's format or \
a feature it uses. The example error below distinguishes them. Example error: \
{example}"
));
}
for relative_path in &files_to_pull {
if dispatched.contains(relative_path) {
continue;
}
failures.record(FailureStage::Pull);
outcomes.push(FileOutcome {
relative_path: relative_path.clone(),
destination_filename: None,
outcome: Outcome::Failed,
stage: Some(FailureStage::Pull),
detail: "file was in this run's pull set but rclone never reported it as copied, \
so it never entered the transcode pipeline"
.to_string(),
});
}
if failures.pull > 0 {
crate::observability::metrics::record_phase_count(
"transform",
"pull",
Outcome::Failed.as_str(),
failures.pull,
None,
);
}
if pull_summary.errors != failures.pull {
tracing::warn!(
pigeon_job = "transform",
rclone_errors = pull_summary.errors,
undelivered_files = failures.pull,
"transform: rclone's pull error count disagrees with the number of undelivered files"
);
}
tracing::info!(
pigeon_job = "transform",
transferred = pull_summary.transferred,
errors = pull_summary.errors,
"transform: pull phase complete"
);
let failed = outcomes.iter().any(|o| o.outcome == Outcome::Failed);
let transcoded = outcomes
.iter()
.filter(|o| o.outcome == Outcome::Transcoded)
.count();
let copied_through = outcomes
.iter()
.filter(|o| o.outcome == Outcome::CopiedThrough)
.count();
tracing::info!(
pigeon_job = "transform",
pulled = pull_summary.transferred,
pushed,
transcoded,
copied_through,
failed_total = failures.total(),
failed_pull = failures.pull,
failed_transcode = failures.transcode,
failed_place = failures.place,
failed_push = failures.push,
skipped_already_done = selection_skipped_already_done,
skipped_apple_double,
"transform: run complete"
);
Ok(TransformSummary {
outcomes,
pulled: pull_summary.transferred,
pushed,
failed,
failures,
skipped_already_done: selection_skipped_already_done,
skipped_apple_double,
pull_errors: pull_summary.errors,
})
}
pub(crate) fn write_report(
local_output: &Path,
summary: &TransformSummary,
) -> Result<PathBuf, String> {
use std::fmt::Write as _;
let mut body =
String::from("source_relative_path\toutcome\tstage\tdestination_filename\tdetail\n");
let mut transcoded = 0;
let mut copied_through = 0;
let mut failed = 0;
for outcome in &summary.outcomes {
match outcome.outcome {
Outcome::Transcoded => transcoded += 1,
Outcome::CopiedThrough => copied_through += 1,
Outcome::Failed => failed += 1,
}
let _ = writeln!(
body,
"{}\t{}\t{}\t{}\t{}",
outcome.relative_path,
outcome.outcome.as_str(),
outcome.stage.map(FailureStage::as_str).unwrap_or(""),
outcome.destination_filename.as_deref().unwrap_or(""),
outcome.detail,
);
}
let _ = writeln!(
body,
"\n{transcoded} transcoded, {copied_through} copied through, {failed} failed."
);
let failures = summary.failures;
let _ = writeln!(
body,
"failures by stage: {} pull, {} transcode, {} place, {} push.",
failures.pull, failures.transcode, failures.place, failures.push
);
let _ = writeln!(
body,
"skipped: {} already done at the destination, {} AppleDouble sidecar(s).",
summary.skipped_already_done, summary.skipped_apple_double
);
let _ = writeln!(
body,
"pull: {} transferred, {} rclone error(s) reported.",
summary.pulled, summary.pull_errors
);
let path = local_output.join("transform-report.txt");
std::fs::write(&path, body)
.map_err(|err| format!("failed to write {}: {err}", path.display()))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
const TEST_RETRIES: usize = 3;
#[test]
fn circuit_breaker_trips_on_a_strong_majority_not_only_unanimity() {
assert!(circuit_breaker_should_trip(
CIRCUIT_BREAKER_SAMPLE_SIZE,
CIRCUIT_BREAKER_SAMPLE_SIZE
));
assert!(circuit_breaker_should_trip(
CIRCUIT_BREAKER_SAMPLE_SIZE,
CIRCUIT_BREAKER_TRIP_THRESHOLD
));
assert!(!circuit_breaker_should_trip(
CIRCUIT_BREAKER_SAMPLE_SIZE,
CIRCUIT_BREAKER_TRIP_THRESHOLD - 1
));
assert!(!circuit_breaker_should_trip(
CIRCUIT_BREAKER_SAMPLE_SIZE - 1,
CIRCUIT_BREAKER_SAMPLE_SIZE - 1
));
assert!(!circuit_breaker_should_trip(0, 0));
}
#[tokio::test]
async fn retry_transcode_unless_fatal_does_not_retry_a_non_retryable_error() {
let attempts = AtomicUsize::new(0);
let result: Result<(), String> =
retry_transcode_unless_fatal(TEST_RETRIES, Duration::from_secs(60), "x.heic", || {
attempts.fetch_add(1, Ordering::SeqCst);
async { Err("ffmpeg failed to transcode x.heic: moov atom not found".to_string()) }
})
.await;
assert!(result.is_err());
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn retry_transcode_unless_fatal_still_retries_a_transient_looking_error_and_eventually_succeeds()
{
let attempts = AtomicUsize::new(0);
let result: Result<&str, String> =
retry_transcode_unless_fatal(TEST_RETRIES, Duration::from_millis(1), "x.png", || {
let count = attempts.fetch_add(1, Ordering::SeqCst) + 1;
async move {
if count < 3 {
Err("Resource temporarily unavailable (os error 11)".to_string())
} else {
Ok("transcoded")
}
}
})
.await;
assert_eq!(result, Ok("transcoded"));
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn retry_transcode_unless_fatal_returns_the_last_error_after_exhausting_retryable_attempts()
{
let attempts = AtomicUsize::new(0);
let result: Result<(), String> =
retry_transcode_unless_fatal(TEST_RETRIES, Duration::from_millis(1), "x.mp4", || {
let count = attempts.fetch_add(1, Ordering::SeqCst) + 1;
async move { Err(format!("Cannot allocate memory (attempt {count})")) }
})
.await;
assert_eq!(
result,
Err(format!("Cannot allocate memory (attempt {TEST_RETRIES})"))
);
assert_eq!(attempts.load(Ordering::SeqCst), TEST_RETRIES);
}
#[tokio::test]
async fn run_transform_job_transcodes_and_pushes_every_pending_file() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source_for_pull.join("a.jpeg"), b"jpeg bytes").unwrap();
std::fs::write(source_for_pull.join("b.jpeg"), b"other jpeg bytes").unwrap();
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Jpeg,
download_transfers: 2,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 2, 2).await.unwrap();
assert!(!summary.failed);
assert_eq!(summary.pushed, 2);
assert_eq!(summary.outcomes.len(), 2);
assert!(
summary
.outcomes
.iter()
.all(|outcome| outcome.outcome == Outcome::CopiedThrough)
);
let pushed_names: Vec<_> = std::fs::read_dir(&destination)
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(pushed_names.len(), 2);
assert!(
std::fs::read_dir(source_dir(&local_output))
.unwrap()
.next()
.is_none(),
"local source/ should be empty after every file is pushed"
);
assert!(
std::fs::read_dir(result_dir(&local_output))
.unwrap()
.next()
.is_none(),
"local result/ should be empty after every file is pushed"
);
let rerun_summary = run_transform_job(&plan, 2, 2).await.unwrap();
assert_eq!(rerun_summary.pushed, 0);
assert!(rerun_summary.outcomes.is_empty());
}
#[tokio::test]
async fn run_transform_job_handles_every_spelling_of_one_input_kind() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source_for_pull.join("a.jpg"), b"jpg bytes").unwrap();
std::fs::write(source_for_pull.join("b.jpeg"), b"jpeg bytes").unwrap();
std::fs::write(source_for_pull.join("c.png"), b"a different format").unwrap();
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output,
input_file_type: InputFileType::Jpeg,
download_transfers: 2,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 2, 2).await.unwrap();
assert!(!summary.failed);
assert_eq!(
summary.pushed, 2,
"both the .jpg and the .jpeg file must be pushed"
);
assert!(
summary
.outcomes
.iter()
.all(|outcome| outcome.outcome == Outcome::CopiedThrough),
"jpeg input is copied through, never transcoded"
);
let mut processed: Vec<&str> = summary
.outcomes
.iter()
.map(|outcome| outcome.relative_path.as_str())
.collect();
processed.sort();
assert_eq!(processed, vec!["a.jpg", "b.jpeg"]);
assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 2);
}
#[tokio::test]
async fn run_transform_job_is_a_clean_no_op_when_the_source_matches_nothing() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source_for_pull.join("notes.txt"), b"not an image").unwrap();
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: dir.path().join("work"),
input_file_type: InputFileType::Jpeg,
download_transfers: 2,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 2, 2).await.unwrap();
assert!(!summary.failed);
assert_eq!(summary.pulled, 0);
assert_eq!(summary.pushed, 0);
assert!(summary.outcomes.is_empty());
assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0);
}
#[tokio::test]
async fn a_mixed_batch_with_one_permanently_failing_file_still_processes_and_pushes_every_other_file()
{
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source_for_pull.join("good-1.heic"), b"not a real heic file").unwrap();
std::fs::write(
source_for_pull.join("good-2.heic"),
b"also not a real heic file",
)
.unwrap();
std::fs::write(
source_for_pull.join("bad.heic"),
b"still not a real heic file",
)
.unwrap();
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Heic,
download_transfers: 3,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 3, 3).await.unwrap();
assert_eq!(summary.outcomes.len(), 3);
assert!(summary.failed);
let failed_paths: HashSet<_> = summary
.outcomes
.iter()
.filter(|outcome| outcome.outcome == Outcome::Failed)
.map(|outcome| outcome.relative_path.clone())
.collect();
assert!(failed_paths.contains("bad.heic"));
for outcome in &summary.outcomes {
if outcome.outcome == Outcome::Failed {
assert!(!outcome.detail.is_empty());
}
}
}
#[tokio::test]
async fn run_transform_job_dispatches_a_file_already_on_disk_from_a_prior_run() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source_for_pull.join("a.jpeg"), b"jpeg bytes").unwrap();
std::fs::create_dir_all(source_dir(&local_output)).unwrap();
std::fs::write(source_dir(&local_output).join("a.jpeg"), b"jpeg bytes").unwrap();
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Jpeg,
download_transfers: 2,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 2, 2).await.unwrap();
assert!(!summary.failed);
assert_eq!(summary.pushed, 1);
assert!(
destination
.join(summary.outcomes[0].destination_filename.as_deref().unwrap())
.exists()
);
}
#[tokio::test]
async fn run_transform_job_pushes_an_already_placed_result_file_without_retranscoding() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source_for_pull.join("a.heic"), b"not a real heic file").unwrap();
std::fs::create_dir_all(source_dir(&local_output)).unwrap();
std::fs::write(
source_dir(&local_output).join("a.heic"),
b"not a real heic file",
)
.unwrap();
let source_path = source_for_pull.display().to_string();
let destination_name = placement::compute_destination_name(&source_path, "a.heic", "jpg");
std::fs::create_dir_all(result_dir(&local_output)).unwrap();
std::fs::write(
result_dir(&local_output).join(&destination_name),
b"already transcoded in a prior run",
)
.unwrap();
let plan = TransformPlan {
source_path,
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Heic,
download_transfers: 2,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 2, 2).await.unwrap();
assert!(!summary.failed);
assert_eq!(summary.pushed, 1);
assert_eq!(
std::fs::read(destination.join(&destination_name)).unwrap(),
b"already transcoded in a prior run"
);
}
#[tokio::test]
async fn run_transform_job_skips_transcode_and_push_for_a_file_already_present_at_the_destination()
{
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source_for_pull.join("a.jpeg"), b"jpeg bytes").unwrap();
let source_path = source_for_pull.display().to_string();
let destination_filename =
placement::compute_destination_name(&source_path, "a.jpeg", "jpg");
std::fs::write(destination.join(&destination_filename), b"already pushed").unwrap();
let plan = TransformPlan {
source_path,
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Jpeg,
download_transfers: 2,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 2, 2).await.unwrap();
assert!(!summary.failed);
assert_eq!(summary.pushed, 0);
assert!(summary.outcomes.is_empty());
assert_eq!(
std::fs::read(destination.join(&destination_filename)).unwrap(),
b"already pushed"
);
assert!(!source_dir(&local_output).join("a.jpeg").exists());
}
#[tokio::test]
async fn run_transform_job_trips_the_circuit_breaker_when_the_first_20_completions_all_fail_the_same_way()
{
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
for i in 0..25 {
std::fs::write(
source_for_pull.join(format!("bad-{i}.heic")),
b"not a real heic file",
)
.unwrap();
}
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Heic,
download_transfers: 5,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let result = run_transform_job(&plan, 5, 5).await;
let err = result.expect_err("a uniformly-corrupt batch must trip the circuit breaker");
assert!(err.contains("circuit breaker"));
assert!(err.contains(&CIRCUIT_BREAKER_SAMPLE_SIZE.to_string()));
}
#[tokio::test]
async fn run_transform_job_does_not_trip_the_circuit_breaker_on_a_mostly_healthy_batch() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
for i in 0..24 {
let path = source_for_pull.join(format!("good-{i}.png"));
generate_test_png(&path).await.unwrap();
}
std::fs::write(source_for_pull.join("bad.png"), b"not a real png file").unwrap();
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Png,
download_transfers: 5,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 5, 5).await.unwrap();
assert!(summary.failed);
assert_eq!(summary.pushed, 24);
assert!(source_dir(&local_output).join("bad.png").exists());
for i in 0..24 {
assert!(
!source_dir(&local_output)
.join(format!("good-{i}.png"))
.exists()
);
}
}
async fn generate_test_png(path: &Path) -> Result<(), String> {
let result = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=64x48:rate=1",
"-frames:v",
"1",
])
.arg(path)
.output()
.await
.map_err(|err| format!("failed to run ffmpeg: {err}"))?;
if !result.status.success() {
return Err(String::from_utf8_lossy(&result.stderr).to_string());
}
Ok(())
}
async fn generate_test_video(path: &Path) -> Result<(), String> {
let result = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=64x48:rate=10",
"-f",
"lavfi",
"-i",
"anullsrc=duration=1",
"-c:v",
"libx264",
"-c:a",
"aac",
])
.arg(path)
.output()
.await
.map_err(|err| format!("failed to run ffmpeg: {err}"))?;
if !result.status.success() {
return Err(String::from_utf8_lossy(&result.stderr).to_string());
}
Ok(())
}
#[tokio::test]
async fn run_transform_job_transcodes_and_pushes_a_video_file() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
if generate_test_video(&source_for_pull.join("a.mp4"))
.await
.is_err()
{
eprintln!("skipping: this ffmpeg build can't generate a test fixture");
return;
}
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Mp4,
download_transfers: 2,
download_checkers: 4,
video_quality: VideoQuality::Low,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 2, 2).await.unwrap();
if summary.failed {
let detail = summary
.outcomes
.iter()
.find(|outcome| outcome.outcome == Outcome::Failed)
.map(|outcome| outcome.detail.as_str())
.unwrap_or_default();
eprintln!("skipping: this ffmpeg build can't encode libx265: {detail}");
return;
}
assert_eq!(summary.pushed, 1);
assert_eq!(summary.outcomes[0].outcome, Outcome::Transcoded);
let pushed_names: Vec<_> = std::fs::read_dir(&destination)
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(pushed_names.len(), 1);
assert!(pushed_names[0].ends_with(".mp4"));
}
#[test]
fn enqueue_does_not_double_dispatch_a_path_already_dispatched_this_run() {
let dir = tempfile::tempdir().unwrap();
let source_dir = dir.path().to_path_buf();
let existing_destination_filenames = HashSet::new();
let mut dispatched = HashSet::new();
let mut queue = VecDeque::new();
enqueue(
"a.png".to_string(),
&source_dir,
"source:png/",
"jpg",
&existing_destination_filenames,
&mut dispatched,
&mut queue,
);
enqueue(
"a.png".to_string(),
&source_dir,
"source:png/",
"jpg",
&existing_destination_filenames,
&mut dispatched,
&mut queue,
);
assert_eq!(queue.len(), 1);
}
#[test]
fn enqueue_skips_a_path_whose_destination_name_already_exists_at_the_destination() {
let dir = tempfile::tempdir().unwrap();
let source_dir = dir.path().to_path_buf();
let source_path = "source:png/";
let mut existing_destination_filenames = HashSet::new();
existing_destination_filenames.insert(placement::compute_destination_name(
source_path,
"a.png",
"jpg",
));
let mut dispatched = HashSet::new();
let mut queue = VecDeque::new();
enqueue(
"a.png".to_string(),
&source_dir,
source_path,
"jpg",
&existing_destination_filenames,
&mut dispatched,
&mut queue,
);
assert!(queue.is_empty());
}
#[test]
fn files_needing_pull_excludes_a_path_already_present_at_the_destination() {
let source_path = "source:png/";
let mut existing_destination_filenames = HashSet::new();
existing_destination_filenames.insert(placement::compute_destination_name(
source_path,
"a.png",
"jpg",
));
let result = files_needing_pull(
vec!["a.png".to_string(), "b.png".to_string()],
source_path,
"jpg",
&existing_destination_filenames,
);
assert_eq!(result.to_pull, vec!["b.png".to_string()]);
assert_eq!(result.skipped_already_done, 1);
assert_eq!(result.skipped_apple_double, 0);
}
#[test]
fn files_needing_pull_excludes_apple_double_sidecars_without_failing_them() {
let selection = files_needing_pull(
vec![
"a.png".to_string(),
"._a.png".to_string(),
"nested/._b.png".to_string(),
],
"source:png/",
"jpg",
&HashSet::new(),
);
assert_eq!(selection.to_pull, vec!["a.png".to_string()]);
assert_eq!(selection.skipped_apple_double, 2);
assert_eq!(selection.skipped_already_done, 0);
}
#[test]
fn enqueue_skips_an_apple_double_sidecar() {
let mut dispatched = HashSet::new();
let mut queue = VecDeque::new();
let outcome = enqueue(
"._a.png".to_string(),
Path::new("/tmp/source"),
"source:png/",
"jpg",
&HashSet::new(),
&mut dispatched,
&mut queue,
);
assert_eq!(outcome, EnqueueOutcome::SkippedAppleDouble);
assert!(queue.is_empty());
assert!(dispatched.is_empty());
}
#[test]
fn enqueue_reports_a_queued_path_and_then_a_duplicate_dispatch() {
let mut dispatched = HashSet::new();
let mut queue = VecDeque::new();
assert_eq!(
enqueue(
"a.png".to_string(),
Path::new("/tmp/source"),
"source:png/",
"jpg",
&HashSet::new(),
&mut dispatched,
&mut queue,
),
EnqueueOutcome::Queued
);
assert_eq!(
enqueue(
"a.png".to_string(),
Path::new("/tmp/source"),
"source:png/",
"jpg",
&HashSet::new(),
&mut dispatched,
&mut queue,
),
EnqueueOutcome::AlreadyDispatched
);
assert_eq!(queue.len(), 1);
}
#[test]
fn undelivered_files_are_exactly_the_intended_set_minus_the_dispatched_set() {
let files_to_pull = vec![
"delivered.png".to_string(),
"never-arrived.png".to_string(),
"also-missing.png".to_string(),
];
let mut dispatched = HashSet::new();
dispatched.insert("delivered.png".to_string());
let mut failures = FailureBreakdown::default();
let mut undelivered = Vec::new();
for relative_path in &files_to_pull {
if dispatched.contains(relative_path) {
continue;
}
failures.record(FailureStage::Pull);
undelivered.push(relative_path.clone());
}
assert_eq!(
undelivered,
vec![
"never-arrived.png".to_string(),
"also-missing.png".to_string()
]
);
assert_eq!(failures.pull, 2);
assert_eq!(failures.total(), 2);
}
#[test]
fn failure_breakdown_tallies_each_stage_independently() {
let mut failures = FailureBreakdown::default();
failures.record(FailureStage::Pull);
failures.record(FailureStage::Transcode);
failures.record(FailureStage::Transcode);
failures.record(FailureStage::Place);
failures.record(FailureStage::Push);
assert_eq!(failures.pull, 1);
assert_eq!(failures.transcode, 2);
assert_eq!(failures.place, 1);
assert_eq!(failures.push, 1);
assert_eq!(failures.total(), 5);
}
#[test]
fn failure_stage_labels_match_the_metric_phase_names() {
assert_eq!(FailureStage::Pull.as_str(), "pull");
assert_eq!(FailureStage::Transcode.as_str(), "transcode");
assert_eq!(FailureStage::Place.as_str(), "place");
assert_eq!(FailureStage::Push.as_str(), "push");
}
#[test]
fn outcome_for_maps_jpeg_to_copied_through_and_others_to_transcoded() {
assert_eq!(outcome_for(InputFileType::Jpeg), Outcome::CopiedThrough);
assert_eq!(outcome_for(InputFileType::Png), Outcome::Transcoded);
assert_eq!(outcome_for(InputFileType::Heic), Outcome::Transcoded);
assert_eq!(outcome_for(InputFileType::Mov), Outcome::Transcoded);
assert_eq!(outcome_for(InputFileType::M4v), Outcome::Transcoded);
assert_eq!(outcome_for(InputFileType::Mp4), Outcome::Transcoded);
}
#[test]
fn write_report_lists_every_outcome_and_a_trailing_summary_line() {
let dir = tempfile::tempdir().unwrap();
let summary = TransformSummary {
outcomes: vec![
FileOutcome {
relative_path: "a.png".to_string(),
destination_filename: Some("a-abc123.jpg".to_string()),
outcome: Outcome::Transcoded,
stage: None,
detail: String::new(),
},
FileOutcome {
relative_path: "bad.heic".to_string(),
destination_filename: None,
outcome: Outcome::Failed,
stage: Some(FailureStage::Transcode),
detail: "ffmpeg: boom".to_string(),
},
],
pulled: 2,
pushed: 1,
failed: true,
failures: FailureBreakdown {
transcode: 1,
..FailureBreakdown::default()
},
skipped_already_done: 3,
skipped_apple_double: 2,
pull_errors: 0,
};
let path = write_report(dir.path(), &summary).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("a.png\ttranscoded\t\ta-abc123.jpg\t"));
assert!(contents.contains("bad.heic\tfailed\ttranscode\t\tffmpeg: boom"));
assert!(contents.contains("1 transcoded, 0 copied through, 1 failed."));
assert!(contents.contains("failures by stage: 0 pull, 1 transcode, 0 place, 0 push."));
assert!(contents.contains("skipped: 3 already done at the destination, 2 AppleDouble"));
assert!(contents.contains("pull: 2 transferred, 0 rclone error(s) reported."));
assert_eq!(path.file_name().unwrap(), "transform-report.txt");
}
#[tokio::test]
async fn run_transform_job_transcodes_an_audio_only_mp4_to_opus() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
let generated = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=2",
"-c:a",
"aac",
"-f",
"mp4",
])
.arg(source_for_pull.join("voice.mp4"))
.output()
.await;
if !generated.map(|out| out.status.success()).unwrap_or(false) {
eprintln!("skipping: this ffmpeg build can't generate an audio-only fixture");
return;
}
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Mp4Audio,
download_transfers: 2,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 1, 1).await.unwrap();
if summary.failures.transcode > 0 {
eprintln!(
"skipping: this ffmpeg build can't encode libopus: {:?}",
summary.outcomes
);
return;
}
assert!(!summary.failed);
assert_eq!(summary.pushed, 1);
assert_eq!(summary.outcomes.len(), 1);
assert_eq!(summary.outcomes[0].outcome, Outcome::Transcoded);
let pushed: Vec<_> = std::fs::read_dir(&destination)
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(pushed.len(), 1);
assert!(
pushed[0].ends_with(".opus"),
"expected a .opus output, got {}",
pushed[0]
);
}
#[tokio::test]
async fn an_mp4_audio_run_over_real_video_deletes_each_rejected_local_source_copy() {
if media::check_ffmpeg_available().await.is_err() {
eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
return;
}
let dir = tempfile::tempdir().unwrap();
let local_output = dir.path().join("work");
let source_for_pull = dir.path().join("pull-source");
let destination = dir.path().join("destination");
std::fs::create_dir_all(&source_for_pull).unwrap();
std::fs::create_dir_all(&destination).unwrap();
for i in 0..3 {
let generated = tokio::process::Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=64x48:rate=5",
"-c:v",
"libx264",
"-f",
"mp4",
])
.arg(source_for_pull.join(format!("video-{i}.mp4")))
.output()
.await;
if !generated.map(|out| out.status.success()).unwrap_or(false) {
eprintln!("skipping: this ffmpeg build can't generate a video fixture");
return;
}
}
let plan = TransformPlan {
source_path: source_for_pull.display().to_string(),
destination_path: destination.display().to_string(),
local_output: local_output.clone(),
input_file_type: InputFileType::Mp4Audio,
download_transfers: 3,
download_checkers: 4,
video_quality: VideoQuality::Medium,
tpslimit: None,
max_pending_downloads: 100,
run_id: "test-run".to_string(),
};
let summary = run_transform_job(&plan, 3, 3)
.await
.expect("3 failures are below the circuit-breaker sample size, so no whole-run abort");
assert!(summary.failed);
assert_eq!(summary.pushed, 0);
assert_eq!(summary.failures.transcode, 3);
assert_eq!(summary.outcomes.len(), 3);
for outcome in &summary.outcomes {
assert_eq!(outcome.stage, Some(FailureStage::Transcode));
assert!(
media::is_wrong_input_kind_error(&outcome.detail),
"expected a wrong-input-kind error, got: {}",
outcome.detail
);
}
assert!(std::fs::read_dir(&destination).unwrap().next().is_none());
for i in 0..3 {
let retained = source_dir(&local_output).join(format!("video-{i}.mp4"));
assert!(
!retained.exists(),
"{} should have been deleted -- it is undamaged and still at --source",
retained.display()
);
}
}
}