Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ struct RestoreSweepPlan {
referenced: HashSet<String>,
live: HashSet<u64>,
dest_dirs: HashSet<String>,
state_keep: HashSet<(String, u64)>,
selections: Vec<(String, Vec<usize>)>,
session_keep: Option<HashSet<String>>,
}
Expand Down Expand Up @@ -4683,8 +4684,21 @@ impl DownloadManager {
.map(|it| it.url().to_string())
.filter(|u| crate::torrent::is_torrent_url(u))
.collect();
let live: HashSet<u64> = self.items().map(|it| it.id()).collect();
// Only rows that can still resume own staging: a Done row has
// delivered, so anything it left in its dest dir is debris.
let live: HashSet<u64> = self
.items()
.filter(|it| it.status() != DownloadStatus::Done)
.map(|it| it.id())
.collect();
let dest_dirs: HashSet<String> = self.items().map(|it| it.dest_dir()).collect();
// State manifests belong to rows that can still resume. Done rows have
// delivered; anything they leave behind is crash debris.
let state_keep: HashSet<(String, u64)> = self
.items()
.filter(|it| it.status() != DownloadStatus::Done)
.map(|it| (it.dest_dir(), it.id()))
.collect();
let session_keep = crate::torrent::session_handle().is_some().then(|| {
self.items()
.filter(|it| {
Expand All @@ -4697,6 +4711,7 @@ impl DownloadManager {
referenced,
live,
dest_dirs,
state_keep,
selections,
session_keep,
}
Expand All @@ -4711,6 +4726,7 @@ impl DownloadManager {
for dest_dir in plan.dest_dirs {
crate::video::sweep_dest_staging(std::path::Path::new(&dest_dir), &plan.live);
}
crate::video::sweep_state_staging(&plan.state_keep);
crate::cookies::sweep_cookie_staging();
plan.session_keep
}
Expand Down
6 changes: 4 additions & 2 deletions src/download_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4549,7 +4549,9 @@ fn remove_cleans_video_staging() {
let manifest_json = format!(
r#"{{"page_url":"https://x.com/u/status/1","quality":"1080p","video_format_id":null,"video_ext":"mp4","audio_format_id":"","audio_ext":"","final_bytes":null,"staging_name":"v.{id}.mp4"}}"#
);
std::fs::write(destdir.join(format!(".{id}.manifest.json")), manifest_json).unwrap();
let manifest_file = crate::video_staging::manifest_path(&destdir, id);
std::fs::create_dir_all(manifest_file.parent().unwrap()).unwrap();
std::fs::write(&manifest_file, manifest_json).unwrap();
for n in ["v.mp4", "v.srt"] {
std::fs::write(destdir.join(n), b"x").unwrap();
}
Expand Down Expand Up @@ -4578,7 +4580,7 @@ fn remove_cleans_video_staging() {
);
manager.remove(id);
assert!(
!destdir.join(format!(".{id}.manifest.json")).exists(),
!manifest_file.exists(),
"staged sidecar must go with the row"
);
assert!(
Expand Down
4 changes: 3 additions & 1 deletion src/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ pub use crate::video_staging::clean_staging;
pub use crate::video_staging::clean_staging_files;
/// Facade: staging/parts/manifest/resume lives in
/// [`video_staging`](crate::video_staging) now.
pub use crate::video_staging::{clean_dest_parts, staging_root, sweep_dest_staging};
pub use crate::video_staging::{
clean_dest_parts, staging_root, sweep_dest_staging, sweep_state_staging,
};
/// Facade: tool provisioning lives in [`video_tools`](crate::video_tools) now.
pub use crate::video_tools::{
install_deno, install_ytdlp, latest_deno_tag, latest_ytdlp_tag, resolve_libraries,
Expand Down
13 changes: 10 additions & 3 deletions src/video_argv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

use crate::video_prefs::subtitle_cli_args;
use crate::video_quality::quality_height;
use crate::video_staging::{VideoManifest, dest_part_path, manifest_path, ytdlp_output_template};
use crate::video_staging::{
VideoManifest, dest_part_path, manifest_path, write_private, ytdlp_output_template,
};
use crate::video_tools::{VideoError, ffmpeg_location_dir};
use std::path::{Path, PathBuf};

Expand All @@ -12,8 +14,13 @@ pub(crate) async fn write_manifest(
item_id: u64,
manifest: &VideoManifest,
) -> Result<(), VideoError> {
let text = serde_json::to_string_pretty(manifest).map_err(VideoError::staging)?;
tokio::fs::write(manifest_path(dir, item_id), text)
// Record the destination at the single writer choke point: callers build
// the selection fields, never the path bookkeeping.
let mut manifest = manifest.clone();
manifest.dest_dir = Some(dir.to_path_buf());
let text = serde_json::to_string_pretty(&manifest).map_err(VideoError::staging)?;
// Private: the manifest carries the page URL and destination path.
write_private(&manifest_path(dir, item_id), text.as_bytes())
.await
.map_err(VideoError::staging)
}
Expand Down
71 changes: 42 additions & 29 deletions src/video_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ use crate::video_spawn::{
};
use crate::video_staging::{
ResumePlan, ResumeQuery, VideoManifest, clean_dest_parts, clean_staging_files, collect_sidecar,
dest_part_path, discover_unified_output, ensure_staging_dir_in, file_len, read_manifest,
release_remux_lease, reserve_remux_temp, resume_plan, sidecar_path_for,
staging_location_for_dest, sweep_partial_remuxes, sweep_staging_preserving_recordings,
write_private,
dest_part_path, discover_unified_output, ensure_staging_dir_in, file_len, info_json_path,
live_info_json_path, read_manifest, release_remux_lease, reserve_remux_temp, resume_plan,
sidecar_path_for, staging_location_for_dest, sweep_partial_remuxes,
sweep_staging_preserving_recordings, write_private,
};
use crate::video_tools::{VideoError, ensure_tool_versions, resolve_libraries};
use crate::video_types::{FetchedVideo, VideoOutcome, yt_dlp_reports_currently_live};
Expand All @@ -47,6 +47,26 @@ pub enum StopIntent {
Discard,
}

/// Remove a single-use probe info JSON on drop. Both attempt paths have early
/// returns (abort, failure, lost race) that would otherwise leak it.
struct InfoJsonGuard {
path: Option<PathBuf>,
}

impl InfoJsonGuard {
fn new(path: Option<PathBuf>) -> Self {
Self { path }
}
}

impl Drop for InfoJsonGuard {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
let _ = std::fs::remove_file(path);
}
}
}

/// Run one attempt: resolve → download parts → merge → rename into place.
/// Returns the final size, or `None` when aborted (the pauser/canceller already set the row status).
///
Expand Down Expand Up @@ -325,6 +345,10 @@ async fn run_video_download_inner(
let plan = resume_plan(&query);
match plan {
ResumePlan::Finished => {
// The finished file is in place; this row's scratch and manifest
// are spent. Sweep before returning, or the bookkeeping outlives
// the download.
sweep_staging_preserving_recordings(&staging, job.item_id);
return Ok(match file_len(&job.dest) {
Some(n) => VideoOutcome::Finished(n),
None => VideoOutcome::Aborted,
Expand All @@ -338,9 +362,6 @@ async fn run_video_download_inner(
}
// Clear this item's staging files (grab-<id>-*), not the dest dir itself: a previous attempt's detached writers may still hold old inodes, so unlink first. Only mismatches/oversize leftovers land here; same-selection resume never does.
crate::video::clean_staging_files(&staging, job.item_id);
// Reclaim a previous attempt's info-JSON temp file (crash debris;
// the normal path deletes it after the attempt).
let _ = std::fs::remove_file(staging.join(format!(".grab-info-{}.json", job.item_id)));
// Dest-dir parts are Grab-namespaced, so a mismatch restarts clean instead of resuming into a foreign lookalike. The finished file itself is never touched.
clean_dest_parts(&job.dest);
// Record this attempt's selection up front: a pause from here on leaves a matchable sidecar, so the next attempt resumes instead of wiping.
Expand All @@ -363,6 +384,7 @@ async fn run_video_download_inner(
// `{stem}.{id}.`; record the prefix so cleanup can reclaim
// them. Manifest-anchored, not inferred.
staging_prefix: Some(unified_staging_prefix(&job.dest, job.item_id)),
dest_dir: None,
},
)
.await?;
Expand Down Expand Up @@ -555,8 +577,8 @@ pub(crate) async fn run_unified_ytdlp(
// resolve above), so every requested format ID is present. yt-dlp still
// re-extracts automatically if a loaded format URL 403/404s (safety net
// for stale signed URLs).
let info_json_path: Option<std::path::PathBuf> = if let Some(json) = job.info_json.as_ref() {
let json_path = staging.join(format!(".grab-info-{}.json", job.item_id));
let json_path: Option<std::path::PathBuf> = if let Some(json) = job.info_json.as_ref() {
let json_path = info_json_path(job.item_id);
// Private write: the JSON can hold signed media URLs and headers.
let written = match serde_json::to_string(json) {
Ok(text) => write_private(&json_path, text.as_bytes()).await.is_ok(),
Expand All @@ -579,6 +601,8 @@ pub(crate) async fn run_unified_ytdlp(
} else {
None
};
// Single-use: removed on drop, including every early return below.
let info_guard = InfoJsonGuard::new(json_path);
// Single-counter progress with the pump's granularity gate; `done` is capped against the metadata total so the bar never passes 100%.
let sent = Arc::new(AtomicU64::new(0));
let report = {
Expand Down Expand Up @@ -619,12 +643,9 @@ pub(crate) async fn run_unified_ytdlp(
timeout,
)
.await;
// The info JSON temp file is single-use: delete it now that yt-dlp has
// read it (or failed to). Staging's manifest cleanup never owns it.
if let Some(path) = info_json_path {
let _ = std::fs::remove_file(path);
}
let (done, after_move, abort_intent) = attempt_result?;
// yt-dlp is done reading it; don't keep signed URLs on disk through merge.
drop(info_guard);
let Some(()) = done else {
// User abort: on Preserve, try merging partial DASH parts into a
// single file instead of abandoning them.
Expand Down Expand Up @@ -1641,6 +1662,7 @@ pub(crate) async fn run_live_ytdlp(
final_bytes: None,
staging_name: Some(staging_name.clone()),
staging_prefix: None,
dest_dir: None,
},
)
.await;
Expand All @@ -1661,7 +1683,7 @@ pub(crate) async fn run_live_ytdlp(
.and_then(|json| serde_json::to_string(json).ok())
{
Some(text) => {
let path = staging.join(format!(".grab-live-{}.json", job.item_id));
let path = live_info_json_path(job.item_id);
// Private write: the JSON can hold signed manifest URLs.
match write_private(&path, text.as_bytes()).await {
Ok(()) => Some(path),
Expand All @@ -1672,23 +1694,11 @@ pub(crate) async fn run_live_ytdlp(
};
// Reclaim a previous run's crash debris (the normal path deletes it below).
if live_info_path.is_none() {
let _ = std::fs::remove_file(staging.join(format!(".grab-live-{}.json", job.item_id)));
let _ = std::fs::remove_file(live_info_json_path(job.item_id));
}
// Deleted on drop: the attempt loop has early returns (quiesce failure,
// nothing recorded) that would otherwise leak it.
struct InfoJsonGuard {
path: Option<std::path::PathBuf>,
}
impl Drop for InfoJsonGuard {
fn drop(&mut self) {
if let Some(p) = self.path.take() {
let _ = std::fs::remove_file(p);
}
}
}
let _info_guard = InfoJsonGuard {
path: live_info_path.clone(),
};
let _info_guard = InfoJsonGuard::new(live_info_path.clone());
let mut downgraded: Option<VideoJob> = None;
let src = 'attempt: loop {
let attempt: &VideoJob = downgraded.as_ref().unwrap_or(job);
Expand Down Expand Up @@ -2075,6 +2085,9 @@ pub(crate) async fn run_live_ytdlp(
Exit::Delivered,
)
.await;
// Delivered: the manifest's job is done. Failure paths above keep it,
// because it is the ownership proof for a salvaged recording.
sweep_staging_preserving_recordings(staging, job.item_id);
Ok(file_len(&job.dest))
}

Expand Down
Loading
Loading