use std::collections::BTreeSet;
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{self, Clear, ClearType},
};
use videoforge::{
ArchiveOptions, ArchiveSummary, ArchivedVideo, ProgressPhase, ProgressSnapshot, VideoForge,
VideoSummary, VideoTarget, verify_archive,
};
use crate::cli::{Cli, VideoArchiveArgs};
use crate::constants::THESA_METADATA_DIR;
use crate::error::{Result, ThesaError};
use crate::manifest::write_archive_manifest;
use crate::terminal::{TerminalSession, draw_full_frame};
use crate::types::{Mode, VideoQuality};
const AUTHORIZATION_PHRASE: &str = "I AM AUTHORIZED";
pub(crate) fn parse_video_target(input: &str) -> Result<VideoTarget> {
videoforge::parse_video_target(input)
.map_err(|error| ThesaError::InvalidTarget(error.to_string()))
}
pub(crate) fn run_videos_mode(
args: &mut Cli,
terminal_session: &mut Option<TerminalSession>,
) -> Result<()> {
let target = parse_video_target(args.target.as_deref().unwrap_or_default())?;
if args.output == Path::new("./archives") {
args.output = PathBuf::from("./archives/videos");
}
validate_video_limits(args.video_max_download_bytes, args.video_timeout_secs)?;
let forge = VideoForge::new();
let timeout = Duration::from_secs(args.video_timeout_secs);
let inspection = if let Some(session) = terminal_session.as_mut() {
inspect_video_tui(
&mut session.terminal,
forge.clone(),
target.clone(),
args.video_quality,
timeout,
)?
} else {
inspect_video(&forge, &target, args.video_quality, timeout)?
};
*terminal_session = None;
run_videoforge_archive(
forge,
target,
inspection,
args.video_quality,
&args.output,
args.skip_existing,
args.video_max_download_bytes,
args.video_timeout_secs,
args.video_authorized,
args.dry_run,
)
}
pub(crate) fn run_videoforge_archive_command(args: &VideoArchiveArgs, dry_run: bool) -> Result<()> {
let target = parse_video_target(&args.target)?;
validate_video_limits(args.max_download_bytes, args.timeout_secs)?;
let forge = VideoForge::new();
let timeout = Duration::from_secs(args.timeout_secs);
let inspection = inspect_video(&forge, &target, args.quality, timeout)?;
run_videoforge_archive(
forge,
target,
inspection,
args.quality,
&args.archive_root,
args.skip_existing,
args.max_download_bytes,
args.timeout_secs,
args.authorized,
dry_run || args.dry_run,
)
}
#[allow(clippy::too_many_arguments)]
fn run_videoforge_archive(
forge: VideoForge,
target: VideoTarget,
summary: VideoSummary,
quality: VideoQuality,
output: &Path,
skip_existing: bool,
max_download_bytes: u64,
timeout_secs: u64,
authorized: bool,
dry_run: bool,
) -> Result<()> {
let timeout = Duration::from_secs(timeout_secs);
print_video_plan(
&summary,
quality,
output,
skip_existing,
max_download_bytes,
timeout_secs,
dry_run,
);
if dry_run {
println!("No download occurred.");
return Ok(());
}
let authorized = if authorized {
true
} else if !io::stdin().is_terminal() {
return Err(ThesaError::Message(
"noninteractive video archival requires explicit --authorized (or --video-authorized in legacy mode)"
.to_string(),
));
} else {
prompt_for_authorization()?
};
if !authorized {
println!("Authorization was not confirmed; no download occurred.");
return Ok(());
}
println!(
"Archiving one explicitly authorized video with VideoForge to {}",
output.display()
);
io::stdout().flush()?;
let wrapper_directory = video_wrapper_directory(output, &target);
let provider_output = wrapper_directory.join("videoforge");
let options = ArchiveOptions {
output: provider_output,
skip_existing,
max_download_bytes,
timeout,
quality: quality.into(),
authorized: true,
};
let mut renderer = io::stderr().is_terminal().then(VideoProgressRenderer::new);
let mut last_plain_phase = None;
let mut last_plain_bytes = 0_u64;
let archive_result =
forge.archive_with_progress(std::slice::from_ref(&target), &options, |snapshot| {
if let Some(renderer) = renderer.as_mut() {
renderer.render(snapshot);
} else if should_print_plain_progress(snapshot, last_plain_phase, last_plain_bytes) {
print_plain_progress(snapshot);
last_plain_phase = Some(snapshot.phase);
last_plain_bytes = snapshot.active_bytes;
}
});
if let Some(renderer) = renderer.as_mut() {
renderer.finish();
}
let archive_summary = archive_result.map_err(|error| videoforge_error("archive", error))?;
let verified_records = validate_and_verify_summary(&target, &archive_summary)?;
let mut manifest_count = 0_usize;
let mut manifest_file_count = 0_usize;
let mut manifest_total_bytes = 0_u64;
for record in &verified_records {
for warning in &record.warnings {
eprintln!(
"VideoForge warning for {}: {}",
record.target.id(),
printable(warning)
);
}
let manifest = write_archive_manifest(
&wrapper_directory,
"video:youtube",
record.target.canonical_url(),
true,
)?;
manifest_count += 1;
manifest_file_count += manifest.archive.file_count;
manifest_total_bytes = manifest_total_bytes.saturating_add(manifest.archive.total_bytes);
println!(
"Video archive: {} (provider files: {}; {})",
wrapper_directory.display(),
record.directory.display(),
record.target.canonical_url()
);
}
println!(
"\nCompleted: {} video archived, {} verified-skipped, {} failed",
archive_summary.archived, archive_summary.skipped, archive_summary.failed
);
println!(
"Manifests: {manifest_count} wrapper-local {THESA_METADATA_DIR}/manifest.json sidecars ({manifest_file_count} files, {manifest_total_bytes} bytes)"
);
if !archive_summary.failures.is_empty() {
eprintln!("Failures:");
for failure in &archive_summary.failures {
eprintln!(" - {}: {}", failure.target.id(), printable(&failure.error));
}
return Err(ThesaError::Message(
"the video archive failed; any successful verified archives were retained".to_string(),
));
}
Ok(())
}
fn video_wrapper_directory(output: &Path, target: &VideoTarget) -> PathBuf {
output.join(target.id())
}
fn validate_video_limits(max_download_bytes: u64, timeout_secs: u64) -> Result<()> {
if max_download_bytes == 0 || timeout_secs == 0 {
Err(ThesaError::Message(
"VideoForge byte and timeout limits must be nonzero".to_string(),
))
} else {
Ok(())
}
}
fn inspect_video(
forge: &VideoForge,
target: &VideoTarget,
quality: VideoQuality,
timeout: Duration,
) -> Result<VideoSummary> {
forge
.inspect(target, quality.into(), timeout)
.map_err(|error| videoforge_error("inspect", error))
}
fn inspect_video_tui(
terminal: &mut scrin::Terminal,
forge: VideoForge,
target: VideoTarget,
quality: VideoQuality,
timeout: Duration,
) -> Result<VideoSummary> {
let loading_label = format!("inspecting exact YouTube video {}", target.id());
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = forge.inspect(&target, quality.into(), timeout);
let _ = tx.send(result);
});
let mut tick = 0_usize;
loop {
match rx.try_recv() {
Ok(result) => return result.map_err(|error| videoforge_error("inspect", error)),
Err(mpsc::TryRecvError::Disconnected) => {
return Err(ThesaError::Message(
"VideoForge inspection worker stopped unexpectedly".to_string(),
));
}
Err(mpsc::TryRecvError::Empty) => {}
}
draw_full_frame(terminal, |frame| {
crate::draw_loading_tui(frame, &loading_label, tick, Mode::Videos, 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_video_plan(
summary: &VideoSummary,
quality: VideoQuality,
output: &Path,
skip_existing: bool,
max_download_bytes: u64,
timeout_secs: u64,
dry_run: bool,
) {
println!(
"{} VideoForge inspection for one exact public YouTube video",
if dry_run { "Dry run:" } else { "Preflight:" }
);
println!("Canonical URL: {}", summary.target.canonical_url());
println!("Title: {}", printable(&summary.title));
println!(
"Uploader: {}",
printable(summary.rights.uploader.as_deref().unwrap_or("unavailable"))
);
println!(
"Channel: {}",
printable(summary.rights.channel.as_deref().unwrap_or("unavailable"))
);
println!(
"Reported license: {}",
printable(
summary
.rights
.reported_license
.as_deref()
.unwrap_or("unavailable")
)
);
println!("Attribution: {}", printable(&summary.rights.attribution));
println!(
"Redistribution warning: {}",
printable(&summary.rights.redistribution_warning)
);
println!(
"Estimated size: {}",
summary
.estimated_bytes
.map(format_bytes)
.unwrap_or_else(|| "unavailable".to_string())
);
println!("Quality: {quality}");
println!(
"Maximum download bytes: {max_download_bytes} ({})",
format_bytes(max_download_bytes)
);
println!("Timeout: {timeout_secs} seconds");
println!("Output: {}", output.display());
if skip_existing {
println!(
"Skip existing: true (VideoForge verification occurs during archive; a matching complete archive may be skipped)"
);
} else {
println!(
"Skip existing: false (recognized archives may be transactionally replaced; foreign directories are refused)"
);
}
}
fn prompt_for_authorization() -> Result<bool> {
println!(
"Authorization is a caller decision. VideoForge metadata and reported licenses do not grant download rights."
);
println!(
"Proceed only if you are authorized under the YouTube Terms of Service, applicable law, and the content owner's rights."
);
print!("Type exactly '{AUTHORIZATION_PHRASE}' to archive, or anything else to cancel: ");
io::stdout().flush()?;
let mut input = String::new();
if io::stdin().lock().read_line(&mut input)? == 0 {
return Ok(false);
}
Ok(authorization_phrase_matches(&input))
}
fn authorization_phrase_matches(input: &str) -> bool {
input
.strip_suffix("\r\n")
.or_else(|| input.strip_suffix('\n'))
.unwrap_or(input)
== AUTHORIZATION_PHRASE
}
#[derive(Clone, Copy)]
struct RecordEvidence<'a> {
target_id: &'a str,
target_url: &'a str,
manifest_id: &'a str,
manifest_url: &'a str,
summary_target_id: &'a str,
summary_target_url: &'a str,
complete: bool,
}
#[derive(Clone, Copy)]
struct FailureEvidence<'a> {
target_id: &'a str,
target_url: &'a str,
}
fn record_evidence(record: &ArchivedVideo) -> RecordEvidence<'_> {
RecordEvidence {
target_id: record.target.id(),
target_url: record.target.canonical_url(),
manifest_id: &record.manifest.video_id,
manifest_url: &record.manifest.canonical_url,
summary_target_id: record.manifest.summary.target.id(),
summary_target_url: record.manifest.summary.target.canonical_url(),
complete: record.manifest.complete,
}
}
fn validate_and_verify_summary<'a>(
target: &VideoTarget,
summary: &'a ArchiveSummary,
) -> Result<Vec<&'a ArchivedVideo>> {
let selected_ids = summary
.selected_ids
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let archived = summary
.archived_videos
.iter()
.map(record_evidence)
.collect::<Vec<_>>();
let skipped = summary
.skipped_videos
.iter()
.map(record_evidence)
.collect::<Vec<_>>();
let failures = summary
.failures
.iter()
.map(|failure| FailureEvidence {
target_id: failure.target.id(),
target_url: failure.target.canonical_url(),
})
.collect::<Vec<_>>();
let eligible_ids = sidecar_eligible_ids(
target.id(),
target.canonical_url(),
&selected_ids,
&archived,
&skipped,
&failures,
summary.archived,
summary.skipped,
summary.failed,
)?;
let records = summary
.archived_videos
.iter()
.chain(&summary.skipped_videos)
.collect::<Vec<_>>();
for record in &records {
if !eligible_ids.contains(record.target.id()) {
return Err(inconsistent_summary_error());
}
let verified = verify_archive(&record.directory)
.map_err(|error| videoforge_error("post-archive verification", error))?;
if verified.directory != record.directory
|| verified.target != record.target
|| verified.manifest != record.manifest
{
return Err(ThesaError::Message(
"VideoForge verification did not reproduce the returned archive record; no Thesa video sidecars were written"
.to_string(),
));
}
}
Ok(records)
}
#[allow(clippy::too_many_arguments)]
fn sidecar_eligible_ids(
expected_id: &str,
expected_url: &str,
selected_ids: &[&str],
archived_records: &[RecordEvidence<'_>],
skipped_records: &[RecordEvidence<'_>],
failures: &[FailureEvidence<'_>],
archived_count: usize,
skipped_count: usize,
failed_count: usize,
) -> Result<BTreeSet<String>> {
let total = archived_count
.checked_add(skipped_count)
.and_then(|count| count.checked_add(failed_count));
let selected = selected_ids.iter().copied().collect::<BTreeSet<_>>();
let archived = archived_records
.iter()
.map(|record| record.target_id)
.collect::<BTreeSet<_>>();
let skipped = skipped_records
.iter()
.map(|record| record.target_id)
.collect::<BTreeSet<_>>();
let failed = failures
.iter()
.map(|failure| failure.target_id)
.collect::<BTreeSet<_>>();
let records_valid = archived_records
.iter()
.chain(skipped_records)
.all(|record| {
record.target_id == expected_id
&& record.target_url == expected_url
&& record.manifest_id == expected_id
&& record.manifest_url == expected_url
&& record.summary_target_id == expected_id
&& record.summary_target_url == expected_url
&& record.complete
});
let failures_valid = failures
.iter()
.all(|failure| failure.target_id == expected_id && failure.target_url == expected_url);
let counts_valid = selected_ids == [expected_id]
&& selected == BTreeSet::from([expected_id])
&& total == Some(1)
&& archived_count == archived_records.len()
&& archived.len() == archived_records.len()
&& skipped_count == skipped_records.len()
&& skipped.len() == skipped_records.len()
&& failed_count == failures.len()
&& failed.len() == failures.len();
let returned = archived
.union(&skipped)
.copied()
.collect::<BTreeSet<_>>()
.union(&failed)
.copied()
.collect::<BTreeSet<_>>();
if !records_valid
|| !failures_valid
|| !counts_valid
|| !archived.is_disjoint(&skipped)
|| !archived.is_disjoint(&failed)
|| !skipped.is_disjoint(&failed)
|| returned != selected
{
return Err(inconsistent_summary_error());
}
Ok(archived
.union(&skipped)
.map(|id| (*id).to_string())
.collect())
}
fn inconsistent_summary_error() -> ThesaError {
ThesaError::Message(
"VideoForge returned an inconsistent archive summary; no Thesa video sidecars were written"
.to_string(),
)
}
fn should_print_plain_progress(
snapshot: &ProgressSnapshot,
last_phase: Option<ProgressPhase>,
last_bytes: u64,
) -> bool {
last_phase != Some(snapshot.phase)
|| snapshot.active_bytes < last_bytes
|| snapshot.active_bytes.saturating_sub(last_bytes) >= 64 * 1024 * 1024
}
fn print_plain_progress(snapshot: &ProgressSnapshot) {
eprintln!(
"VideoForge {} [{}] {} (archived={}, skipped={}, failed={})",
phase_label(snapshot.phase),
snapshot.target.as_ref().map_or("video", VideoTarget::id),
format_progress_bytes(snapshot),
snapshot.archived,
snapshot.skipped,
snapshot.failed
);
}
struct VideoProgressRenderer {
stderr: io::Stderr,
wrote: bool,
}
impl VideoProgressRenderer {
fn new() -> Self {
let mut stderr = io::stderr();
let _ = execute!(stderr, cursor::Hide);
Self {
stderr,
wrote: false,
}
}
fn render(&mut self, snapshot: &ProgressSnapshot) {
let target = snapshot.target.as_ref().map_or("video", VideoTarget::id);
let speed = snapshot.speed_bytes_per_second.and_then(|speed| {
speed
.is_finite()
.then(|| format!("{}/s", format_bytes(speed.max(0.0) as u64)))
});
let eta = snapshot.eta.map(|eta| format!("eta {}s", eta.as_secs()));
let details = [speed, eta]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" | ");
let line = format!(
"videoforge {} | {target} | {} | {} | ok={} skip={} fail={}",
phase_label(snapshot.phase),
format_progress_bytes(snapshot),
details,
snapshot.archived,
snapshot.skipped,
snapshot.failed
);
let width = terminal::size()
.map(|(width, _)| width.saturating_sub(1) as usize)
.unwrap_or(100);
let line = truncate(&line, width.max(16));
let _ = execute!(
self.stderr,
cursor::MoveToColumn(0),
Clear(ClearType::CurrentLine)
);
let _ = write!(self.stderr, "{line}");
let _ = self.stderr.flush();
self.wrote = true;
}
fn finish(&mut self) {
if self.wrote {
let _ = writeln!(self.stderr);
self.wrote = false;
}
let _ = execute!(self.stderr, cursor::Show);
let _ = self.stderr.flush();
}
}
impl Drop for VideoProgressRenderer {
fn drop(&mut self) {
let _ = execute!(self.stderr, cursor::Show);
}
}
fn phase_label(phase: ProgressPhase) -> &'static str {
match phase {
ProgressPhase::Inspecting => "inspecting",
ProgressPhase::Downloading => "downloading",
ProgressPhase::Verifying => "verifying",
ProgressPhase::Installing => "installing",
ProgressPhase::Skipped => "skipped",
ProgressPhase::Failed => "failed",
ProgressPhase::Complete => "complete",
}
}
fn format_progress_bytes(snapshot: &ProgressSnapshot) -> String {
snapshot.active_total_bytes.map_or_else(
|| format_bytes(snapshot.active_bytes),
|total| {
format!(
"{}/{}",
format_bytes(snapshot.active_bytes),
format_bytes(total)
)
},
)
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0_usize;
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 printable(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect()
}
fn truncate(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
value.to_string()
} else {
format!(
"{}...",
value
.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>()
)
}
}
fn videoforge_error(context: &str, error: impl std::fmt::Display) -> ThesaError {
ThesaError::Message(format!("videoforge {context} failed: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
const ID: &str = "dQw4w9WgXcQ";
const URL: &str = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
fn record<'a>(id: &'a str, url: &'a str, complete: bool) -> RecordEvidence<'a> {
RecordEvidence {
target_id: id,
target_url: url,
manifest_id: id,
manifest_url: url,
summary_target_id: id,
summary_target_url: url,
complete,
}
}
#[test]
fn parses_only_exact_supported_youtube_urls() {
let target = parse_video_target("https://youtu.be/dQw4w9WgXcQ").unwrap();
assert_eq!(target.id(), ID);
assert_eq!(target.canonical_url(), URL);
assert!(parse_video_target("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=x").is_err());
assert!(parse_video_target("https://twitter.com/example/status/123").is_err());
assert!(parse_video_target("search:example").is_err());
}
#[test]
fn authorization_requires_the_exact_phrase() {
assert!(authorization_phrase_matches("I AM AUTHORIZED"));
assert!(authorization_phrase_matches("I AM AUTHORIZED\n"));
assert!(authorization_phrase_matches("I AM AUTHORIZED\r\n"));
assert!(!authorization_phrase_matches(" I AM AUTHORIZED\n"));
assert!(!authorization_phrase_matches("I AM AUTHORIZED \n"));
assert!(!authorization_phrase_matches("i am authorized\n"));
assert!(!authorization_phrase_matches("yes\n"));
}
#[test]
fn summary_identity_validation_is_exhaustive() {
assert!(
sidecar_eligible_ids(ID, URL, &[ID], &[record(ID, URL, true)], &[], &[], 1, 0, 0)
.is_ok()
);
assert!(
sidecar_eligible_ids(
ID,
URL,
&[ID],
&[record(
ID,
"https://www.youtube.com/watch?v=AAAAAAAAAAA",
true
)],
&[],
&[],
1,
0,
0
)
.is_err()
);
assert!(
sidecar_eligible_ids(ID, URL, &[ID], &[record(ID, URL, false)], &[], &[], 1, 0, 0)
.is_err()
);
assert!(sidecar_eligible_ids(ID, URL, &["AAAAAAAAAAA"], &[], &[], &[], 0, 0, 0).is_err());
assert!(
sidecar_eligible_ids(
ID,
URL,
&[ID],
&[record(ID, URL, true)],
&[record(ID, URL, true)],
&[],
1,
1,
0
)
.is_err()
);
}
#[test]
fn sidecars_are_eligible_only_for_returned_archived_or_skipped_records() {
let archived =
sidecar_eligible_ids(ID, URL, &[ID], &[record(ID, URL, true)], &[], &[], 1, 0, 0)
.unwrap();
assert_eq!(archived, BTreeSet::from([ID.to_string()]));
let skipped =
sidecar_eligible_ids(ID, URL, &[ID], &[], &[record(ID, URL, true)], &[], 0, 1, 0)
.unwrap();
assert_eq!(skipped, BTreeSet::from([ID.to_string()]));
let failed = sidecar_eligible_ids(
ID,
URL,
&[ID],
&[],
&[],
&[FailureEvidence {
target_id: ID,
target_url: URL,
}],
0,
0,
1,
)
.unwrap();
assert!(failed.is_empty());
}
#[test]
fn thesa_sidecar_wrapper_does_not_modify_the_videoforge_archive() {
let target = parse_video_target(URL).unwrap();
let wrapper = video_wrapper_directory(Path::new("archives/videos"), &target);
let provider_archive = wrapper.join("videoforge").join(ID);
assert_eq!(wrapper, Path::new("archives/videos").join(ID));
assert_eq!(
provider_archive,
Path::new("archives/videos")
.join(ID)
.join("videoforge")
.join(ID)
);
assert!(
!wrapper
.join(THESA_METADATA_DIR)
.starts_with(&provider_archive)
);
}
}