diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 63fd55a..a20ff6e 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -214,7 +214,7 @@ pub fn list_root_entries( e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, - COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, + COALESCE(SUM(b.byte_size), 0) + COALESCE((SELECT SUM(b2.byte_size) FROM archived_entries c2 JOIN entry_artifacts ea2 ON ea2.entry_id = c2.id JOIN blobs b2 ON b2.id = ea2.blob_id WHERE c2.parent_entry_id = e.id), 0) AS total_artifact_bytes, NULL AS parent_entry_uid, EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, e.cached_bytes, @@ -689,7 +689,7 @@ pub fn parse_search_query(raw: &str) -> Result { const ENTRY_SELECT_COLS: &str = "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \ e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \ - COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \ + COALESCE(SUM(b.byte_size), 0) + COALESCE((SELECT SUM(b2.byte_size) FROM archived_entries c2 JOIN entry_artifacts ea2 ON ea2.entry_id = c2.id JOIN blobs b2 ON b2.id = ea2.blob_id WHERE c2.parent_entry_id = e.id), 0) AS total_artifact_bytes, \ parent.entry_uid AS parent_entry_uid, \ EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, \ e.cached_bytes, \ diff --git a/crates/archivr-core/src/capture.rs b/crates/archivr-core/src/capture.rs index 72b7af1..7d838d8 100644 --- a/crates/archivr-core/src/capture.rs +++ b/crates/archivr-core/src/capture.rs @@ -90,10 +90,13 @@ pub struct CaptureConfig { /// Route WebPage captures through the Freedium mirror to bypass paywalls. /// The original locator is still recorded in the DB; only the fetch URL changes. pub via_freedium: bool, - /// Per-item quality overrides for playlist captures. + /// Per-item quality overrides and include-set for playlist captures. /// Keys are yt-dlp video IDs (e.g. "dQw4w9WgXcQ"); values are quality strings /// accepted by `quality_format` ("best", "audio", "1080p", etc.). - /// Empty map = apply the global `quality` to all items. + /// - Empty map: all playlist items are downloaded using the global `quality`. + /// - Non-empty map: ONLY items whose ID appears as a key are downloaded; + /// items absent from the map are skipped entirely. This is how the UI's + /// per-video delete button excludes specific videos from a capture. pub per_item_quality: HashMap, /// When true, skip playlist items whose URL is already archived as a child /// of any container entry with the same canonical playlist URL. @@ -1139,6 +1142,16 @@ pub fn perform_capture( if config.sync && already_archived.contains(&playlist_item.url) { continue; } + // Per-item exclusion: when per_item_quality is non-empty (frontend + // probe ran and the user confirmed the item list), only download + // items whose ID appears in the map. IDs absent from a non-empty + // map were removed by the user via the delete button before submit. + if !config.per_item_quality.is_empty() + && !config.per_item_quality.contains_key(&playlist_item.id) + { + continue; + } + let child_timestamp = format!( "{}-{}", diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index a7bb4f6..d26c563 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -872,13 +872,14 @@ async fn capture_handler( ))); } } - // NOTE: the server does not enforce that every playlist item has a per_item_quality - // entry. Items without an override receive the global `quality` value, which - // yt-dlp applies as a format-selector cap (e.g. bestvideo[height<=1080]) and - // falls back gracefully to the best available format below that cap. The - // "must choose quality for unsupported videos" invariant is enforced by the + // per_item_quality semantics (enforced in capture.rs): + // - Absent or empty map: all playlist items are downloaded; quality is the + // global `quality` field applied as a yt-dlp cap with graceful fallback. + // - Non-empty map: ONLY items whose yt-dlp ID appears as a key are downloaded; + // absent IDs are skipped. This is how the frontend's delete-item button works. + // The "must choose quality for unsupported videos" invariant is enforced by the // frontend before submission; a direct API caller bypassing the UI accepts - // yt-dlp's standard cap-and-fallback behavior. + // yt-dlp's standard cap-and-fallback behavior for items it includes. let mounted = mounted_archive(&state, &archive_id)?; let archive_paths = archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;