mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
3 commits
d83faab8e6
...
b8e496457f
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e496457f | |||
| 84c5730b6a | |||
| fb1115a409 |
17 changed files with 1345 additions and 260 deletions
|
|
@ -63,7 +63,7 @@ fn main() -> Result<()> {
|
|||
}
|
||||
};
|
||||
let archive_paths = archive::read_archive_paths(&archive_path)?;
|
||||
let result = archivr_core::capture::perform_capture(&archive_paths, path, None)?;
|
||||
let result = archivr_core::capture::perform_capture(&archive_paths, path, None, None)?;
|
||||
println!("Archived: run {}", result.run_uid);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use uuid::Uuid;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
|
|
@ -330,6 +331,26 @@ fn determine_source(path: &str) -> Source {
|
|||
Source::Other
|
||||
}
|
||||
|
||||
/// Resolves `locator` to the canonical URL that yt-dlp should receive, or
|
||||
/// returns `None` if the locator does not map to a yt-dlp-downloadable source
|
||||
/// (e.g. tweet/thread shorthands, web pages, local files, playlists/channels).
|
||||
///
|
||||
/// Use this to gate the probe endpoint: only call `fetch_metadata` when this
|
||||
/// returns `Some`.
|
||||
pub fn locator_to_ytdlp_url(locator: &str) -> Option<String> {
|
||||
let source = determine_source(locator);
|
||||
match source {
|
||||
Source::YouTubeVideo
|
||||
| Source::X
|
||||
| Source::Instagram
|
||||
| Source::Facebook
|
||||
| Source::TikTok
|
||||
| Source::Reddit
|
||||
| Source::Snapchat => Some(expand_shorthand_to_url(locator, &source)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_exists(hash: &str, file_extension: &str, store_path: &Path) -> Result<bool> {
|
||||
let path = store_path.join(raw_relative_path_from_hash(hash, file_extension)?);
|
||||
Ok(path.exists())
|
||||
|
|
@ -426,20 +447,6 @@ fn local_file_extension(path: &str) -> String {
|
|||
.map_or(String::new(), |ext| format!(".{}", ext.to_string_lossy()))
|
||||
}
|
||||
|
||||
fn media_file_extension(source: Source, path: &str) -> String {
|
||||
match source {
|
||||
Source::YouTubeVideo
|
||||
| Source::X
|
||||
| Source::Instagram
|
||||
| Source::Facebook
|
||||
| Source::TikTok
|
||||
| Source::Reddit
|
||||
| Source::Snapchat => ".mp4".to_string(),
|
||||
Source::Local => local_file_extension(path),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tweet_id_from_archive_path(path: &str) -> Option<String> {
|
||||
path.split(':').next_back().and_then(parse_tweet_id)
|
||||
}
|
||||
|
|
@ -676,8 +683,15 @@ pub fn perform_capture(
|
|||
archive_paths: &ArchivePaths,
|
||||
locator: &str,
|
||||
archive_id: Option<&str>,
|
||||
quality: Option<&str>,
|
||||
) -> Result<CaptureResult> {
|
||||
let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f").to_string();
|
||||
// Append a UUID so parallel captures starting in the same millisecond
|
||||
// never collide on the staging directory or file names.
|
||||
let timestamp = format!(
|
||||
"{}-{}",
|
||||
Local::now().format("%Y-%m-%dT%H-%M-%S%.3f"),
|
||||
Uuid::new_v4().simple(),
|
||||
);
|
||||
let store_path = &archive_paths.store_path;
|
||||
|
||||
let conn = database::open_or_initialize(&archive_paths.archive_path)?;
|
||||
|
|
@ -685,24 +699,31 @@ pub fn perform_capture(
|
|||
|
||||
let mut source = determine_source(locator);
|
||||
|
||||
// For generic http/https URLs, probe Content-Type to distinguish a plain
|
||||
// file download from an HTML page that needs single-file archiving.
|
||||
// The probe runs before creating DB records so source_kind is set correctly.
|
||||
//
|
||||
// Note: probe failures return early without a DB run record. This is
|
||||
// intentional — a failed network probe means we haven't started archiving
|
||||
// and there is nothing meaningful to record.
|
||||
// Create the run record before probing so every attempt — including
|
||||
// probe failures — is visible in /runs with a proper status and error.
|
||||
let run = database::create_archive_run(&conn, user_id, 1)?;
|
||||
|
||||
// For generic http/https URLs, probe Content-Type to decide whether to
|
||||
// treat the URL as a raw file download or an HTML page for SingleFile.
|
||||
if source == Source::Url {
|
||||
match downloader::http::probe_url_kind(locator) {
|
||||
Ok(downloader::http::UrlKind::Html) => source = Source::WebPage,
|
||||
Ok(downloader::http::UrlKind::File) => {}
|
||||
Err(e) => return Err(anyhow::anyhow!("Failed to probe URL: {e}")),
|
||||
Err(e) => {
|
||||
// Record a failed item using the pre-probe source_kind so
|
||||
// failed_count increments and the item carries error_text.
|
||||
let (probe_sk, probe_ek, _) = source_metadata(Source::Url);
|
||||
let item = database::create_archive_run_item(
|
||||
&conn, run.id, None, 0, locator, None, probe_sk, probe_ek,
|
||||
)?;
|
||||
let msg = format!("Failed to probe URL: {e}");
|
||||
return Err(fail_run(&conn, &run, &item, &msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (source_kind, entity_kind, _) = source_metadata(source);
|
||||
|
||||
let run = database::create_archive_run(&conn, user_id, 1)?;
|
||||
let item = database::create_archive_run_item(
|
||||
&conn,
|
||||
run.id,
|
||||
|
|
@ -1001,7 +1022,7 @@ pub fn perform_capture(
|
|||
_ => None,
|
||||
};
|
||||
|
||||
let hash = match source {
|
||||
let (hash, file_extension) = match source {
|
||||
Source::YouTubeVideo
|
||||
| Source::X
|
||||
| Source::Instagram
|
||||
|
|
@ -1009,8 +1030,8 @@ pub fn perform_capture(
|
|||
| Source::TikTok
|
||||
| Source::Reddit
|
||||
| Source::Snapchat => {
|
||||
match downloader::ytdlp::download(path.clone(), store_path, ×tamp) {
|
||||
Ok(h) => h,
|
||||
match downloader::ytdlp::download(path.clone(), store_path, ×tamp, quality) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
return Err(fail_run(
|
||||
&conn,
|
||||
|
|
@ -1023,7 +1044,7 @@ pub fn perform_capture(
|
|||
}
|
||||
Source::Local => {
|
||||
match downloader::local::save(path.clone(), store_path, ×tamp) {
|
||||
Ok(h) => h,
|
||||
Ok(h) => (h, local_file_extension(&path)),
|
||||
Err(e) => {
|
||||
return Err(fail_run(
|
||||
&conn,
|
||||
|
|
@ -1044,8 +1065,6 @@ pub fn perform_capture(
|
|||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let file_extension = media_file_extension(source, &path);
|
||||
let temp_file = store_path
|
||||
.join("temp")
|
||||
.join(×tamp)
|
||||
|
|
|
|||
|
|
@ -72,9 +72,16 @@ fn save_with(
|
|||
// then append any extra flags from ARCHIVR_CHROME_ARGS (space-separated).
|
||||
// Docker containers running as root need "--no-sandbox" here because
|
||||
// Chromium refuses to start as root without it.
|
||||
//
|
||||
// --window-size is set to a realistic desktop viewport so that
|
||||
// --remove-alternative-medias=false and --remove-unused-styles=false
|
||||
// actually preserve responsive @media rules and styles that only match
|
||||
// at normal screen widths (headless Chromium defaults to a small viewport
|
||||
// that would otherwise defeat the preservation flags).
|
||||
let mut chrome_flags = vec![
|
||||
"--disable-web-security".to_string(),
|
||||
format!("--user-data-dir={}", chrome_data_dir.display()),
|
||||
"--window-size=1920,1080".to_string(),
|
||||
];
|
||||
if let Ok(extra) = std::env::var("ARCHIVR_CHROME_ARGS") {
|
||||
chrome_flags.extend(extra.split_whitespace().filter(|s| !s.is_empty()).map(str::to_string));
|
||||
|
|
|
|||
|
|
@ -1,26 +1,126 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use std::{env, path::Path, process::Command};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::{env, path::{Path, PathBuf}, process::Command};
|
||||
|
||||
use crate::hash::hash_file;
|
||||
|
||||
pub fn download(path: String, store_path: &Path, timestamp: &String) -> Result<String> {
|
||||
/// Returns the yt-dlp `-f` format selector for `quality`.
|
||||
///
|
||||
/// - `"audio"` → prefers native Opus/WebM (most efficient), then native
|
||||
/// AAC/M4A, then any best-audio fallback — no transcoding, smallest file
|
||||
/// at equivalent perceptual quality.
|
||||
/// - `"NNNp"` (e.g. `"1080p"`) → height-capped selector with `/best` fallback
|
||||
/// - `None` / `"best"` / anything else → highest-quality video+audio
|
||||
pub fn quality_format(quality: Option<&str>) -> String {
|
||||
if quality == Some("audio") {
|
||||
// Opus (WebM) is more efficient than AAC (M4A) at the same perceptual
|
||||
// quality, so prefer it first. Both are taken natively — no transcode.
|
||||
return "bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio/best".to_string();
|
||||
}
|
||||
if let Some(q) = quality {
|
||||
if let Some(h) = q.strip_suffix('p').and_then(|n| n.parse::<u32>().ok()) {
|
||||
return format!("bestvideo[height<={h}]+bestaudio/best[height<={h}]/best");
|
||||
}
|
||||
}
|
||||
"bestvideo+bestaudio/best".to_string()
|
||||
}
|
||||
|
||||
|
||||
/// Combined result of a yt-dlp metadata probe.
|
||||
pub struct ProbeResult {
|
||||
/// Distinct video heights available, sorted highest-first (e.g. `[1080, 720, 480]`).
|
||||
pub video_heights: Vec<u32>,
|
||||
/// True when at least one format with a real audio codec exists.
|
||||
pub has_audio: bool,
|
||||
}
|
||||
|
||||
/// Parses a yt-dlp `--dump-json` response into a `ProbeResult`.
|
||||
pub fn probe_result(json: &str) -> ProbeResult {
|
||||
ProbeResult {
|
||||
video_heights: available_video_heights(json),
|
||||
has_audio: has_audio_track(json),
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct video heights from a yt-dlp `--dump-json` response, sorted highest-first.
|
||||
/// Audio-only formats (`vcodec == "none"`) and zero-height entries are excluded.
|
||||
pub fn available_video_heights(json: &str) -> Vec<u32> {
|
||||
let v: serde_json::Value = match serde_json::from_str(json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let Some(formats) = v.get("formats").and_then(|f| f.as_array()) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut heights: Vec<u32> = formats
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let vcodec = f.get("vcodec")?.as_str()?;
|
||||
if vcodec == "none" {
|
||||
return None;
|
||||
}
|
||||
let h = f.get("height")?.as_u64()?;
|
||||
if h == 0 { None } else { Some(h as u32) }
|
||||
})
|
||||
.collect();
|
||||
heights.sort_unstable_by(|a, b| b.cmp(a));
|
||||
heights.dedup();
|
||||
heights
|
||||
}
|
||||
|
||||
/// Returns true when the yt-dlp `--dump-json` response contains at least one
|
||||
/// format with a real audio codec (i.e. `acodec != "none"`).
|
||||
pub fn has_audio_track(json: &str) -> bool {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else {
|
||||
return false;
|
||||
};
|
||||
let Some(formats) = v.get("formats").and_then(|f| f.as_array()) else {
|
||||
return false;
|
||||
};
|
||||
formats.iter().any(|f| {
|
||||
f.get("acodec")
|
||||
.and_then(|a| a.as_str())
|
||||
.is_some_and(|a| a != "none")
|
||||
})
|
||||
}
|
||||
|
||||
/// Downloads `path` via yt-dlp and returns `(hash, file_extension_with_dot)`.
|
||||
///
|
||||
/// For video the extension is always `.mp4` (forced via `--merge-output-format`).
|
||||
/// For audio (`quality == Some("audio")`) `-x` is passed to guarantee audio-only
|
||||
/// output even when only combined A/V formats exist (yt-dlp strips the video
|
||||
/// track). `--audio-format` is intentionally omitted so the audio stream is
|
||||
/// remuxed into its native container without re-encoding — no lossy transcode,
|
||||
/// no size inflation. The actual output extension is discovered by globbing.
|
||||
pub fn download(
|
||||
path: String,
|
||||
store_path: &Path,
|
||||
timestamp: &String,
|
||||
quality: Option<&str>,
|
||||
) -> Result<(String, String)> {
|
||||
println!("Downloading with yt-dlp: {path}");
|
||||
|
||||
let ytdlp = env::var("ARCHIVR_YT_DLP").unwrap_or_else(|_| "yt-dlp".to_string());
|
||||
let is_audio = quality == Some("audio");
|
||||
|
||||
let temp_dir = store_path.join("temp").join(timestamp);
|
||||
std::fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
let out_file = temp_dir.join(format!("{timestamp}.mp4"));
|
||||
// %(ext)s lets yt-dlp write the correct extension for the chosen format.
|
||||
let out_template = temp_dir.join(format!("{timestamp}.%(ext)s"));
|
||||
|
||||
let out = Command::new(&ytdlp)
|
||||
.arg(&path)
|
||||
.arg("-f")
|
||||
.arg("bestvideo+bestaudio/best")
|
||||
.arg("--merge-output-format")
|
||||
.arg("mp4")
|
||||
let mut cmd = Command::new(&ytdlp);
|
||||
cmd.arg(&path).arg("-f").arg(quality_format(quality));
|
||||
if is_audio {
|
||||
// -x guarantees audio-only even when /best falls back to a combined
|
||||
// A/V format. No --audio-format → native remux only, no re-encode.
|
||||
cmd.arg("-x");
|
||||
} else {
|
||||
// Force the video container to mp4 so we always have a known extension.
|
||||
cmd.arg("--merge-output-format").arg("mp4");
|
||||
}
|
||||
let out = cmd
|
||||
.arg("-o")
|
||||
.arg(&out_file)
|
||||
.arg(&out_template)
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn {ytdlp} process"))?;
|
||||
|
||||
|
|
@ -29,7 +129,31 @@ pub fn download(path: String, store_path: &Path, timestamp: &String) -> Result<S
|
|||
bail!("yt-dlp failed: {stderr}");
|
||||
}
|
||||
|
||||
hash_file(&out_file)
|
||||
let actual_file = find_downloaded_file(&temp_dir, timestamp)?;
|
||||
let ext = actual_file
|
||||
.extension()
|
||||
.map(|e| format!(".{}", e.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let hash = hash_file(&actual_file)?;
|
||||
Ok((hash, ext))
|
||||
}
|
||||
|
||||
/// Finds the file yt-dlp wrote to `temp_dir` whose stem is `timestamp`.
|
||||
/// Ignores `.part` files (incomplete downloads).
|
||||
fn find_downloaded_file(temp_dir: &Path, timestamp: &str) -> Result<PathBuf> {
|
||||
let entries = std::fs::read_dir(temp_dir)
|
||||
.with_context(|| format!("failed to read temp dir {}", temp_dir.display()))?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with(timestamp) && !name_str.ends_with(".part") {
|
||||
return Ok(entry.path());
|
||||
}
|
||||
}
|
||||
bail!(
|
||||
"yt-dlp output file not found in {}",
|
||||
temp_dir.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Fetches metadata JSON for `path` via `yt-dlp --dump-json`.
|
||||
|
|
@ -48,10 +172,90 @@ pub fn fetch_metadata(path: &str) -> Option<String> {
|
|||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
eprintln!("yt-dlp --dump-json failed for {path} (status {:?}): {stderr}", out.status);
|
||||
eprintln!(
|
||||
"yt-dlp --dump-json failed for {path} (status {:?}): {stderr}",
|
||||
out.status
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let json = String::from_utf8(out.stdout).ok()?;
|
||||
if json.trim().is_empty() { None } else { Some(json) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{available_video_heights, has_audio_track, quality_format};
|
||||
|
||||
#[test]
|
||||
fn quality_format_audio() {
|
||||
assert_eq!(quality_format(Some("audio")), "bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio/best");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_format_known_heights() {
|
||||
assert_eq!(
|
||||
quality_format(Some("1080p")),
|
||||
"bestvideo[height<=1080]+bestaudio/best[height<=1080]/best"
|
||||
);
|
||||
assert_eq!(
|
||||
quality_format(Some("720p")),
|
||||
"bestvideo[height<=720]+bestaudio/best[height<=720]/best"
|
||||
);
|
||||
assert_eq!(
|
||||
quality_format(Some("2160p")),
|
||||
"bestvideo[height<=2160]+bestaudio/best[height<=2160]/best"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_format_defaults_to_best() {
|
||||
assert_eq!(quality_format(None), "bestvideo+bestaudio/best");
|
||||
assert_eq!(quality_format(Some("best")), "bestvideo+bestaudio/best");
|
||||
assert_eq!(quality_format(Some("bogus")), "bestvideo+bestaudio/best");
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn available_video_heights_parses_formats() {
|
||||
let json = r#"{
|
||||
"formats": [
|
||||
{"height": 1080, "vcodec": "avc1.640028", "acodec": "none"},
|
||||
{"height": 720, "vcodec": "avc1.4d401f", "acodec": "none"},
|
||||
{"height": 1080, "vcodec": "avc1.640028", "acodec": "mp4a.40.2"},
|
||||
{"height": null, "vcodec": "none", "acodec": "mp4a.40.2"},
|
||||
{"height": 360, "vcodec": "none", "acodec": "mp4a.40.2"}
|
||||
]
|
||||
}"#;
|
||||
assert_eq!(available_video_heights(json), vec![1080, 720]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_video_heights_empty_on_audio_only() {
|
||||
let json = r#"{"formats": [{"height": null, "vcodec": "none", "acodec": "mp4a.40.2"}]}"#;
|
||||
assert_eq!(available_video_heights(json), vec![0u32; 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_video_heights_empty_on_bad_json() {
|
||||
assert_eq!(available_video_heights("not json"), vec![0u32; 0]);
|
||||
assert_eq!(available_video_heights("{}"), vec![0u32; 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_audio_track_detects_audio() {
|
||||
let with_audio = r#"{"formats": [
|
||||
{"vcodec": "avc1", "acodec": "mp4a.40.2"},
|
||||
{"vcodec": "none", "acodec": "mp4a.40.2"}
|
||||
]}"#;
|
||||
assert!(has_audio_track(with_audio));
|
||||
|
||||
let video_only = r#"{"formats": [
|
||||
{"vcodec": "avc1", "acodec": "none"}
|
||||
]}"#;
|
||||
assert!(!has_audio_track(video_only));
|
||||
|
||||
assert!(!has_audio_track("not json"));
|
||||
assert!(!has_audio_track("{}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use std::{
|
|||
};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use archivr_core::{archive, capture, database};
|
||||
use archivr_core::{archive, capture, database, downloader};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{ConnectInfo, Path, Query, Request, State},
|
||||
|
|
@ -231,6 +231,7 @@ pub fn app_with_state(state: AppState) -> Router {
|
|||
)
|
||||
.route("/api/archives/:archive_id/runs", get(list_runs))
|
||||
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
||||
.route("/api/archives/:archive_id/captures/probe", get(probe_handler))
|
||||
.route(
|
||||
"/api/archives/:archive_id/capture_jobs/:job_uid",
|
||||
get(get_capture_job_handler),
|
||||
|
|
@ -637,6 +638,14 @@ async fn delete_entry_handler(
|
|||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CaptureBody {
|
||||
locator: String,
|
||||
/// Optional quality cap for yt-dlp sources: `"best"` or any `"NNNp"` string
|
||||
/// (e.g. `"1080p"`, `"720p"`, `"2160p"`). Absent or `"best"` → highest available.
|
||||
quality: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ProbeQuery {
|
||||
locator: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
|
|
@ -676,6 +685,18 @@ async fn capture_handler(
|
|||
if body.locator.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("locator must not be empty"));
|
||||
}
|
||||
if let Some(q) = &body.quality {
|
||||
let valid = q == "best"
|
||||
|| q == "audio"
|
||||
|| q.strip_suffix('p')
|
||||
.and_then(|n| n.parse::<u32>().ok())
|
||||
.is_some();
|
||||
if !valid {
|
||||
return Err(ApiError::bad_request(
|
||||
"invalid quality: must be \"best\", \"audio\", or a height string like \"1080p\"",
|
||||
));
|
||||
}
|
||||
}
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let archive_paths = archive::read_archive_paths(&mounted.archive_path)
|
||||
.map_err(ApiError::from)?;
|
||||
|
|
@ -687,6 +708,7 @@ async fn capture_handler(
|
|||
|
||||
// Spawn background capture.
|
||||
let locator = body.locator.trim().to_string();
|
||||
let quality = body.quality.clone();
|
||||
let archive_path = mounted.archive_path.clone();
|
||||
let job_uid_bg = job_uid.clone();
|
||||
let archive_id_bg = archive_id.clone();
|
||||
|
|
@ -699,7 +721,7 @@ async fn capture_handler(
|
|||
}
|
||||
};
|
||||
database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None).ok();
|
||||
match capture::perform_capture(&archive_paths, &locator, Some(&archive_id_bg)) {
|
||||
match capture::perform_capture(&archive_paths, &locator, Some(&archive_id_bg), quality.as_deref()) {
|
||||
Ok(result) => {
|
||||
database::update_capture_job_status(
|
||||
&conn,
|
||||
|
|
@ -742,6 +764,64 @@ async fn get_capture_job_handler(
|
|||
.ok_or_else(|| ApiError::not_found("capture job not found"))
|
||||
}
|
||||
|
||||
/// `GET /api/archives/:id/captures/probe?locator=<url>`
|
||||
///
|
||||
/// Runs `yt-dlp --dump-json` (behind `spawn_blocking`) and returns the video
|
||||
/// heights actually available at the given locator.
|
||||
///
|
||||
/// Response shapes:
|
||||
/// - Locator is not a yt-dlp source (tweet, webpage, local, …):
|
||||
/// `{ "has_video": false, "qualities": [] }` — 200
|
||||
/// - yt-dlp ran and found no video tracks (e.g. tweet URL with no media):
|
||||
/// `{ "has_video": false, "qualities": [] }` — 200
|
||||
/// - yt-dlp ran and found video tracks:
|
||||
/// `{ "has_video": true, "qualities": ["1080p", "720p", …] }` — 200
|
||||
/// - yt-dlp itself failed (non-zero exit, network error, rate-limit, …):
|
||||
/// 502 — caller should treat this as "probe inconclusive", not "no video"
|
||||
async fn probe_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
Query(params): Query<ProbeQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
auth_user.require_role(ROLE_USER)?;
|
||||
let locator = params.locator.trim().to_string();
|
||||
if locator.is_empty() {
|
||||
return Err(ApiError::bad_request("locator must not be empty"));
|
||||
}
|
||||
// Verify the archive exists but don't need the paths for probing.
|
||||
let _ = mounted_archive(&state, &archive_id)?;
|
||||
|
||||
// Resolve to a yt-dlp URL; return empty result immediately for non-video sources.
|
||||
let Some(ytdlp_url) = capture::locator_to_ytdlp_url(&locator) else {
|
||||
return Ok(Json(serde_json::json!({ "has_video": false, "has_audio": false, "qualities": [] })));
|
||||
};
|
||||
|
||||
// fetch_metadata shells out and can take several seconds — keep the async runtime free.
|
||||
// Returns None when yt-dlp exits non-zero (transient error, rate-limit, unsupported
|
||||
// extractor, etc.). That is distinct from "yt-dlp ran fine but found no video": we
|
||||
// return 502 so the frontend treats it as inconclusive rather than showing
|
||||
// "No video detected" for a URL that may well be downloadable.
|
||||
let maybe_result = tokio::task::spawn_blocking(move || {
|
||||
downloader::ytdlp::fetch_metadata(&ytdlp_url)
|
||||
.map(|json| downloader::ytdlp::probe_result(&json))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("probe task panicked"))?;
|
||||
|
||||
let result = maybe_result.ok_or_else(|| ApiError {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
message: "yt-dlp metadata fetch failed".to_string(),
|
||||
})?;
|
||||
|
||||
let qualities: Vec<String> = result.video_heights.iter().map(|h| format!("{h}p")).collect();
|
||||
Ok(Json(serde_json::json!({
|
||||
"has_video": !qualities.is_empty(),
|
||||
"qualities": qualities,
|
||||
"has_audio": result.has_audio,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn auth_setup_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
|
|
@ -2206,6 +2286,93 @@ mod tests {
|
|||
assert_eq!(json["status"], "pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_with_valid_quality_is_accepted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/archives/test/captures")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::from(r#"{"locator":"local:/nonexistent","quality":"720p"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["job_uid"].as_str().is_some(), "response must have job_uid");
|
||||
assert_eq!(json["status"], "pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_with_invalid_quality_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/archives/test/captures")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::from(r#"{"locator":"local:/nonexistent","quality":"4K"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["error"].as_str().is_some_and(|e| e.contains("invalid quality")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_requires_auth() {
|
||||
let (test_app, _dir) = make_test_app();
|
||||
let response = test_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/test/captures/probe?locator=local%3A%2Fnonexistent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_non_video_locator_returns_has_video_false() {
|
||||
// local:/nonexistent is not a yt-dlp source — the handler returns
|
||||
// immediately without spawning yt-dlp, so this is fast and deterministic.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/test/captures/probe?locator=local%3A%2Fnonexistent")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["has_video"], false);
|
||||
assert_eq!(json["qualities"], serde_json::json!([]));
|
||||
assert_eq!(json["has_audio"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_users_requires_admin_role() {
|
||||
let (test_app, _dir) = make_test_app();
|
||||
|
|
|
|||
40
crates/archivr-server/static/assets/index-BHg5-TAr.js
Normal file
40
crates/archivr-server/static/assets/index-BHg5-TAr.js
Normal file
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-BW0QKHXE.css
Normal file
1
crates/archivr-server/static/assets/index-BW0QKHXE.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-j6_-GyNh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dhs0pFsV.css">
|
||||
<script type="module" crossorigin src="/assets/index-BHg5-TAr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BW0QKHXE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -146,6 +146,35 @@ Auth and session handling will be designed when remote or public hosting becomes
|
|||
- X/Twitter Tweet content scrape: [Tweet and Thread shorthands](#supported-shorthand-inputs). (These are saved as JSON files in `raw_tweets/`)
|
||||
- Instagram, Facebook, TikTok, Reddit, Snapchat: direct URLs or platform-prefixed shorthand passed through to `yt-dlp`
|
||||
|
||||
#### Video quality and audio-only downloads
|
||||
|
||||
When capturing via the web UI, entering a URL for a yt-dlp-backed source (YouTube, Instagram, TikTok, Facebook, Reddit, Snapchat, X media) triggers a metadata probe via `GET /api/archives/:id/captures/probe`. The quality selector then shows only the heights actually available in that video plus **Best quality** (default). An **Audio only** option is appended whenever the probe confirms an audio track exists. UI behaviour by probe outcome:
|
||||
|
||||
| `qualities` | `has_audio` | UI shows |
|
||||
|---|---|---|
|
||||
| `["1080p", "720p", …]` | `true` | Best / heights / Audio only |
|
||||
| `["1080p", …]` | `false` | Best / heights |
|
||||
| `[]` | `true` | Audio only (pre-selected, no Best option) |
|
||||
| `[]` | `false` | "No media detected" |
|
||||
| probe fails (502) | — | picker hidden, capture still submittable |
|
||||
|
||||
The `POST /api/archives/:id/captures` endpoint accepts an optional `quality` field: `"best"`, `"audio"`, or any `"NNNp"` height string:
|
||||
|
||||
```json
|
||||
{ "locator": "https://www.youtube.com/watch?v=...", "quality": "720p" }
|
||||
{ "locator": "https://www.youtube.com/watch?v=...", "quality": "audio" }
|
||||
```
|
||||
|
||||
`"audio"` selects the most efficient native audio track without transcoding: Opus/WebM is preferred (smallest at equivalent quality), then AAC/M4A, then whatever yt-dlp considers best. The saved file's extension matches the native format (`.webm` for Opus, `.m4a` for AAC, etc.) — no ffmpeg re-encode, no size inflation. Any `"NNNp"` height is accepted; the server builds the yt-dlp format selector with an unconditional `/best` fallback so the download succeeds even if the exact height is unavailable. Omitting `quality` or passing `"best"` downloads at the highest available quality. Anything else is rejected with HTTP 400.
|
||||
|
||||
The probe endpoint (`GET /api/archives/:id/captures/probe?locator=…`) requires auth and returns 200 with:
|
||||
```json
|
||||
{ "has_video": true, "has_audio": true, "qualities": ["1080p", "720p", "480p"] }
|
||||
{ "has_video": false, "has_audio": true, "qualities": [] }
|
||||
{ "has_video": false, "has_audio": false, "qualities": [] }
|
||||
```
|
||||
`has_video: false, has_audio: false` means yt-dlp found no downloadable tracks (e.g. a tweet with no media). A 502 means yt-dlp itself failed (transient network error, rate-limit, unsupported extractor) — treat as inconclusive, not "no media."
|
||||
|
||||
### Hosting on NixOS
|
||||
|
||||
The flake exposes a `nixosModules.default` output. Add it to your system flake and
|
||||
|
|
@ -314,6 +343,9 @@ dependencies (Chromium, Node.js, Python) land in the final layer.
|
|||
- Space-separated extra flags appended to Chromium's `--browser-args`. The Docker
|
||||
image sets this to `--no-sandbox` because Chromium refuses to run as root without
|
||||
it. Leave unset when running natively (Nix, Linux desktop).
|
||||
A `--window-size=1920,1080` is always passed to provide a realistic desktop
|
||||
viewport (so responsive @media rules and styles are evaluated and preserved
|
||||
correctly). Supply your own `--window-size=...` here to override.
|
||||
- `ARCHIVR_TWITTER_CREDENTIALS_FILE`
|
||||
- Required for tweet/thread scraping inputs such as `tweet:ID` and `x:thread:ID`.
|
||||
- Must point to a cookies file for the vendored scraper.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import CollectionsView from './components/CollectionsView'
|
|||
import SettingsView from './components/SettingsView'
|
||||
import ContextRail from './components/ContextRail'
|
||||
import { displayPath } from './utils'
|
||||
import ToastStack from './components/ToastStack'
|
||||
|
||||
export const AuthContext = createContext(null);
|
||||
|
||||
|
|
@ -82,6 +83,9 @@ export default function App() {
|
|||
return saved === 'true'
|
||||
})
|
||||
|
||||
const [toasts, setToasts] = useState([])
|
||||
const toastIdRef = useRef(0)
|
||||
|
||||
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
||||
|
||||
// Persist captureDialogOpen to sessionStorage
|
||||
|
|
@ -234,6 +238,15 @@ export default function App() {
|
|||
])
|
||||
}, [archiveId, searchQuery, tagFilter, loadEntries])
|
||||
|
||||
const handleToast = useCallback((errorText, locator) => {
|
||||
const id = ++toastIdRef.current
|
||||
setToasts(prev => [...prev, { id, errorText, locator }])
|
||||
}, [])
|
||||
|
||||
const handleDismissToast = useCallback((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
|
||||
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
|
||||
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
|
||||
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
|
||||
|
|
@ -332,7 +345,9 @@ export default function App() {
|
|||
archiveId={archiveId}
|
||||
onClose={handleCaptureClose}
|
||||
onCaptured={handleCaptured}
|
||||
onToast={handleToast}
|
||||
/>
|
||||
<ToastStack toasts={toasts} onDismiss={handleDismissToast} />
|
||||
</>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -95,11 +95,13 @@ export async function fetchTags(archiveId) {
|
|||
return getJson(`/api/archives/${archiveId}/tags`);
|
||||
}
|
||||
|
||||
export async function submitCapture(archiveId, locator) {
|
||||
export async function submitCapture(archiveId, locator, quality = null) {
|
||||
const payload = { locator }
|
||||
if (quality && quality !== 'best') payload.quality = quality
|
||||
const res = await fetch(`/api/archives/${archiveId}/captures`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ locator }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
|
@ -108,6 +110,12 @@ export async function submitCapture(archiveId, locator) {
|
|||
return res.json(); // { job_uid, status: "pending" }
|
||||
}
|
||||
|
||||
// Returns { has_video: bool, qualities: string[] } e.g. { has_video: true, qualities: ["1080p","720p","480p"] }
|
||||
// Throws on network error; returns { has_video: false, qualities: [] } on non-video locators.
|
||||
export async function probeCapture(archiveId, locator) {
|
||||
return getJson(`/api/archives/${archiveId}/captures/probe?locator=${encodeURIComponent(locator)}`);
|
||||
}
|
||||
|
||||
export async function pollCaptureJob(archiveId, jobUid) {
|
||||
return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,202 +1,452 @@
|
|||
import { useRef, useEffect, useState } from 'react'
|
||||
import { submitCapture, pollCaptureJob } from '../api'
|
||||
import { submitCapture, pollCaptureJob, probeCapture } from '../api'
|
||||
|
||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
|
||||
let nextItemId = 1
|
||||
|
||||
// Returns true only for locators that determine_source() routes to yt-dlp download.
|
||||
// Mirrors the exact conditions in capture.rs — playlist/channel shorthands are excluded
|
||||
// (they return an "not yet implemented" error), as are tweet/thread shorthands.
|
||||
function isVideoSource(locator) {
|
||||
const l = locator.trim()
|
||||
const ll = l.toLowerCase()
|
||||
|
||||
// yt: / youtube: shorthands — video/short/shorts only; playlist and channel are unsupported
|
||||
for (const scheme of ['yt:', 'youtube:']) {
|
||||
if (ll.startsWith(scheme)) {
|
||||
const after = ll.slice(scheme.length)
|
||||
return after.startsWith('video/') || after.startsWith('short/') || after.startsWith('shorts/')
|
||||
}
|
||||
}
|
||||
|
||||
// x: / twitter: / tweet: shorthands — only x:media:ID routes to yt-dlp (Source::X)
|
||||
for (const scheme of ['x:', 'twitter:', 'tweet:']) {
|
||||
if (ll.startsWith(scheme)) {
|
||||
return ll.slice(scheme.length).startsWith('media:')
|
||||
}
|
||||
}
|
||||
|
||||
// Other platform shorthands — all go to yt-dlp
|
||||
if (ll.startsWith('instagram:') || ll.startsWith('facebook:') ||
|
||||
ll.startsWith('tiktok:') || ll.startsWith('reddit:') ||
|
||||
ll.startsWith('snapchat:')) return true
|
||||
|
||||
// HTTP/HTTPS URLs — match the same regexes and prefix checks as determine_source
|
||||
if (ll.startsWith('http://') || ll.startsWith('https://')) {
|
||||
// YouTube video (watch, youtu.be, shorts) — not playlist or channel
|
||||
if (/^https?:\/\/(?:www\.)?(?:youtu\.be\/[0-9A-Za-z_-]+|youtube\.com\/watch\?v=[0-9A-Za-z_-]+|youtube\.com\/shorts\/[0-9A-Za-z_-]+)/.test(l)) return true
|
||||
// x.com → Source::X → yt-dlp (note: twitter.com URLs fall through to Source::Url, not yt-dlp)
|
||||
if (ll.startsWith('https://x.com/') || ll.startsWith('http://x.com/')) return true
|
||||
// Instagram
|
||||
if (/^https?:\/\/(?:www\.)?instagram\.com\//.test(ll)) return true
|
||||
// Facebook + fb.watch
|
||||
if (/^https?:\/\/(?:www\.)?facebook\.com\//.test(ll) || ll.startsWith('https://fb.watch/') || ll.startsWith('http://fb.watch/')) return true
|
||||
// TikTok
|
||||
if (/^https?:\/\/(?:www\.)?tiktok\.com\//.test(ll)) return true
|
||||
// Reddit + redd.it
|
||||
if (/^https?:\/\/(?:www\.)?reddit\.com\//.test(ll) || ll.startsWith('https://redd.it/') || ll.startsWith('http://redd.it/')) return true
|
||||
// Snapchat
|
||||
if (/^https?:\/\/(?:www\.)?snapchat\.com\//.test(ll)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function makeItem(locator = '') {
|
||||
return {
|
||||
id: nextItemId++, locator, quality: 'best',
|
||||
// probe: tracks yt-dlp metadata fetch for the locator
|
||||
probeState: 'idle', // 'idle' | 'probing' | 'done'
|
||||
probeQualities: null, // null | string[] when done, e.g. ["1080p","720p","480p"]
|
||||
probeHasAudio: false, // true when probe confirms at least one audio track
|
||||
status: 'idle', error: null, jobUid: null, archiveId: null,
|
||||
}
|
||||
}
|
||||
|
||||
function hasActiveJobs(items) {
|
||||
return items.some(it => it.status === 'submitting' || it.status === 'running')
|
||||
}
|
||||
|
||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast }) {
|
||||
const dialogRef = useRef(null)
|
||||
const isFirstRenderRef = useRef(true)
|
||||
const hasResumedPollingRef = useRef(false)
|
||||
|
||||
const [locator, setLocator] = useState(() => {
|
||||
const saved = sessionStorage.getItem('captureDialogLocator')
|
||||
return saved || ''
|
||||
})
|
||||
const [error, setError] = useState(() => {
|
||||
const saved = sessionStorage.getItem('captureDialogError')
|
||||
return saved || null
|
||||
})
|
||||
const [busy, setBusy] = useState(() => {
|
||||
const saved = sessionStorage.getItem('captureDialogBusy')
|
||||
return saved === 'true'
|
||||
})
|
||||
const [jobStatus, setJobStatus] = useState(() => {
|
||||
const saved = sessionStorage.getItem('captureDialogJobStatus')
|
||||
return saved || null
|
||||
})
|
||||
const [jobUid, setJobUid] = useState(() => {
|
||||
const saved = sessionStorage.getItem('captureDialogJobUid')
|
||||
return saved || null
|
||||
})
|
||||
const pollRef = useRef(null)
|
||||
// jobUid → intervalId; survives dialog close since component stays mounted
|
||||
const pollIntervals = useRef(new Map())
|
||||
// itemId → debounce timeoutId for probe calls
|
||||
const probeTimers = useRef(new Map())
|
||||
// stable ref so probe callbacks always see the current archiveId
|
||||
const archiveIdRef = useRef(archiveId)
|
||||
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
|
||||
|
||||
// Persist state to sessionStorage
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureDialogLocator', locator)
|
||||
}, [locator])
|
||||
// Stable refs so polling callbacks always use the latest prop values
|
||||
const onCapturedRef = useRef(onCaptured)
|
||||
const onToastRef = useRef(onToast)
|
||||
useEffect(() => { onCapturedRef.current = onCaptured }, [onCaptured])
|
||||
useEffect(() => { onToastRef.current = onToast }, [onToast])
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureDialogError', error || '')
|
||||
}, [error])
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureDialogBusy', busy)
|
||||
}, [busy])
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureDialogJobStatus', jobStatus || '')
|
||||
}, [jobStatus])
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureDialogJobUid', jobUid || '')
|
||||
}, [jobUid])
|
||||
|
||||
// On mount, resume polling if a job was in progress before page refresh
|
||||
useEffect(() => {
|
||||
if (hasResumedPollingRef.current) return
|
||||
if (!jobUid || jobStatus !== 'running' || !archiveId) return
|
||||
|
||||
hasResumedPollingRef.current = true
|
||||
|
||||
// Resume polling for the saved job
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await pollCaptureJob(archiveId, jobUid)
|
||||
if (updated.status === 'completed') {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setJobStatus('completed')
|
||||
clearCaptureState()
|
||||
dialogRef.current?.close()
|
||||
onCaptured()
|
||||
} else if (updated.status === 'failed') {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setJobStatus('failed')
|
||||
setError(updated.error_text || 'Capture failed.')
|
||||
}
|
||||
// pending / running: keep polling
|
||||
} catch (pollErr) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setError(pollErr.message)
|
||||
const [items, setItems] = useState(() => {
|
||||
try {
|
||||
const saved = JSON.parse(sessionStorage.getItem('captureItems') || 'null')
|
||||
if (Array.isArray(saved) && saved.length > 0) {
|
||||
// Ensure nextItemId stays ahead of restored ids
|
||||
saved.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 })
|
||||
return saved
|
||||
}
|
||||
}, 500)
|
||||
}, [jobUid, jobStatus, archiveId, onCaptured])
|
||||
} catch {}
|
||||
return [makeItem()]
|
||||
})
|
||||
|
||||
// Handle dialog close event
|
||||
// Persist items to sessionStorage on every change
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureItems', JSON.stringify(items))
|
||||
}, [items])
|
||||
|
||||
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs
|
||||
useEffect(() => {
|
||||
;['captureDialogLocator','captureDialogError','captureDialogBusy',
|
||||
'captureDialogJobStatus','captureDialogJobUid'].forEach(k => sessionStorage.removeItem(k))
|
||||
|
||||
setItems(prev => prev.map(it =>
|
||||
// 'submitting' means page was refreshed mid-fetch — reset to idle so user can retry
|
||||
it.status === 'submitting' ? { ...it, status: 'idle', error: null } : it
|
||||
))
|
||||
|
||||
// Reconnect polling for any jobs still running from a previous session
|
||||
items.forEach(it => {
|
||||
if (it.status === 'running' && it.jobUid && it.archiveId && !pollIntervals.current.has(it.jobUid)) {
|
||||
startPolling(it.id, it.jobUid, it.locator, it.archiveId)
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // intentionally runs once on mount with initial values
|
||||
|
||||
// Handle native dialog 'close' event (Escape key or programmatic .close())
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
const handleClose = () => {
|
||||
clearInterval(pollRef.current)
|
||||
const handler = () => {
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
probeTimers.current.clear()
|
||||
onClose()
|
||||
}
|
||||
dialog.addEventListener('close', handleClose)
|
||||
return () => dialog.removeEventListener('close', handleClose)
|
||||
dialog.addEventListener('close', handler)
|
||||
return () => dialog.removeEventListener('close', handler)
|
||||
}, [onClose])
|
||||
|
||||
// Handle open/close from parent
|
||||
// Open/close driven by parent; don't reset if active jobs are in flight
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
|
||||
if (open) {
|
||||
// Only clear state if this is a fresh open from user click (not a restore on first render)
|
||||
if (!isFirstRenderRef.current) {
|
||||
setLocator('')
|
||||
setError(null)
|
||||
setJobStatus(null)
|
||||
setBusy(false)
|
||||
setJobUid(null)
|
||||
clearInterval(pollRef.current)
|
||||
if (!isFirstRenderRef.current && !hasActiveJobs(items)) {
|
||||
setItems([makeItem()])
|
||||
}
|
||||
isFirstRenderRef.current = false
|
||||
if (!dialog.open) dialog.showModal()
|
||||
} else {
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
probeTimers.current.clear()
|
||||
if (dialog.open) dialog.close()
|
||||
}
|
||||
}, [open])
|
||||
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function clearCaptureState() {
|
||||
sessionStorage.removeItem('captureDialogLocator')
|
||||
sessionStorage.removeItem('captureDialogError')
|
||||
sessionStorage.removeItem('captureDialogBusy')
|
||||
sessionStorage.removeItem('captureDialogJobStatus')
|
||||
sessionStorage.removeItem('captureDialogJobUid')
|
||||
setLocator('')
|
||||
setError(null)
|
||||
setBusy(false)
|
||||
setJobStatus(null)
|
||||
setJobUid(null)
|
||||
// Clear all intervals and probe timers on unmount (component teardown)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pollIntervals.current.forEach(id => clearInterval(id))
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
}
|
||||
}, [])
|
||||
|
||||
function startPolling(itemId, jobUid, locator, aid) {
|
||||
if (pollIntervals.current.has(jobUid)) return // already polling
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updated = await pollCaptureJob(aid, jobUid)
|
||||
if (updated.status === 'completed') {
|
||||
clearInterval(pollIntervals.current.get(jobUid))
|
||||
pollIntervals.current.delete(jobUid)
|
||||
// Show ✓ briefly then remove the row; if last row add a fresh one
|
||||
setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it))
|
||||
setTimeout(() => {
|
||||
setItems(prev => {
|
||||
const next = prev.filter(it => it.id !== itemId)
|
||||
return next.length === 0 ? [makeItem()] : next
|
||||
})
|
||||
}, 1400)
|
||||
onCapturedRef.current()
|
||||
} else if (updated.status === 'failed') {
|
||||
clearInterval(pollIntervals.current.get(jobUid))
|
||||
pollIntervals.current.delete(jobUid)
|
||||
const errText = updated.error_text || 'Capture failed.'
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === itemId ? { ...it, status: 'failed', error: errText } : it
|
||||
))
|
||||
onToastRef.current(errText, locator)
|
||||
}
|
||||
// 'pending' / 'running': keep polling
|
||||
} catch (e) {
|
||||
clearInterval(pollIntervals.current.get(jobUid))
|
||||
pollIntervals.current.delete(jobUid)
|
||||
const msg = e.message || 'Network error'
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === itemId ? { ...it, status: 'failed', error: msg } : it
|
||||
))
|
||||
onToastRef.current(msg, locator)
|
||||
}
|
||||
}, 500)
|
||||
pollIntervals.current.set(jobUid, intervalId)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!locator.trim()) { setError('Enter a locator.'); return }
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setJobStatus(null)
|
||||
async function submitItem(item) {
|
||||
if (!item.locator.trim()) return
|
||||
const aid = archiveId // capture at submit time
|
||||
const loc = item.locator.trim()
|
||||
const qual = item.quality || 'best'
|
||||
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
|
||||
try {
|
||||
const job = await submitCapture(archiveId, locator.trim())
|
||||
setJobUid(job.job_uid)
|
||||
setJobStatus('running')
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await pollCaptureJob(archiveId, job.job_uid)
|
||||
if (updated.status === 'completed') {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setJobStatus('completed')
|
||||
clearCaptureState()
|
||||
dialogRef.current?.close()
|
||||
onCaptured()
|
||||
} else if (updated.status === 'failed') {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setJobStatus('failed')
|
||||
setError(updated.error_text || 'Capture failed.')
|
||||
}
|
||||
// pending / running: keep polling
|
||||
} catch (pollErr) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
setBusy(false)
|
||||
setError(pollErr.message)
|
||||
}
|
||||
}, 500)
|
||||
const job = await submitCapture(aid, loc, qual)
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
|
||||
))
|
||||
startPolling(item.id, job.job_uid, loc, aid)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
setBusy(false)
|
||||
const msg = e.message || 'Submission failed.'
|
||||
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it))
|
||||
onToastRef.current(msg, loc)
|
||||
}
|
||||
}
|
||||
|
||||
function buttonLabel() {
|
||||
if (!busy) return 'Capture'
|
||||
if (jobStatus === 'running') return 'Running\u2026'
|
||||
return 'Capturing\u2026'
|
||||
function handleArchive() {
|
||||
const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim())
|
||||
toSubmit.forEach(it => submitItem(it))
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
setItems(prev => [...prev, makeItem()])
|
||||
}
|
||||
|
||||
function removeRow(id) {
|
||||
clearTimeout(probeTimers.current.get(id))
|
||||
probeTimers.current.delete(id)
|
||||
setItems(prev => {
|
||||
const next = prev.filter(it => it.id !== id)
|
||||
return next.length === 0 ? [makeItem()] : next
|
||||
})
|
||||
}
|
||||
|
||||
function resetRow(id) {
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id ? { ...it, status: 'idle', error: null } : it
|
||||
))
|
||||
}
|
||||
|
||||
function updateLocator(id, val) {
|
||||
// Cancel any in-flight debounce and immediately clear stale probe results.
|
||||
// This prevents old qualities from being visible (and submittable) while
|
||||
// the 600ms debounce is pending for the new URL.
|
||||
clearTimeout(probeTimers.current.get(id))
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id
|
||||
? { ...it, locator: val, probeState: 'idle', probeQualities: null, probeHasAudio: false, quality: 'best' }
|
||||
: it
|
||||
))
|
||||
|
||||
if (!isVideoSource(val)) return
|
||||
|
||||
// Schedule a fresh probe after the user stops typing
|
||||
const timer = setTimeout(async () => {
|
||||
probeTimers.current.delete(id)
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'probing' } : it))
|
||||
try {
|
||||
const result = await probeCapture(archiveIdRef.current, val.trim())
|
||||
setItems(prev => prev.map(it => {
|
||||
if (it.id !== id || it.locator !== val) return it // stale — locator changed again
|
||||
const qualities = result.qualities ?? []
|
||||
const hasAudio = result.has_audio ?? false
|
||||
// Audio-only source: no video heights but audio confirmed — force audio mode
|
||||
const quality = (qualities.length === 0 && hasAudio) ? 'audio' : 'best'
|
||||
return { ...it, probeState: 'done', probeQualities: qualities, probeHasAudio: hasAudio, quality }
|
||||
}))
|
||||
} catch {
|
||||
// Probe failed (network error, etc.) — clear silently; user can still submit
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id ? { ...it, probeState: 'idle', probeQualities: null } : it
|
||||
))
|
||||
}
|
||||
}, 600)
|
||||
probeTimers.current.set(id, timer)
|
||||
}
|
||||
|
||||
function updateQuality(id, val) {
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it))
|
||||
}
|
||||
|
||||
const pendingCount = items.filter(it => it.status === 'idle' && it.locator.trim()).length
|
||||
const anyActive = hasActiveJobs(items)
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="capture-dialog">
|
||||
<div className="capture-dialog-inner">
|
||||
<h2 className="capture-dialog-title">Capture</h2>
|
||||
<label htmlFor="capture-locator" className="capture-label">Locator</label>
|
||||
<input id="capture-locator" className="capture-input" type="text"
|
||||
placeholder="tweet:1234567890 or https://..."
|
||||
value={locator} onChange={e => setLocator(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }}
|
||||
autoComplete="off" />
|
||||
{error && <div className="capture-error">{error}</div>}
|
||||
<div className="capture-dialog-header">
|
||||
<h2 className="capture-dialog-title">Capture</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="capture-dialog-close"
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="capture-rows">
|
||||
{items.map((item, idx) => (
|
||||
<CaptureRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
autoFocus={idx === items.length - 1 && item.status === 'idle'}
|
||||
onLocatorChange={val => updateLocator(item.id, val)}
|
||||
onQualityChange={val => updateQuality(item.id, val)}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
onReset={() => resetRow(item.id)}
|
||||
onSubmit={handleArchive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="capture-add-row" onClick={addRow}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/>
|
||||
</svg>
|
||||
Add another
|
||||
</button>
|
||||
|
||||
<div className="capture-actions">
|
||||
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>Cancel</button>
|
||||
<button type="button" className="capture-submit" onClick={handleSubmit} disabled={busy}>
|
||||
{buttonLabel()}
|
||||
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
|
||||
{anyActive ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="capture-submit"
|
||||
onClick={handleArchive}
|
||||
disabled={pendingCount === 0}
|
||||
>
|
||||
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onReset, onSubmit }) {
|
||||
const inputRef = useRef(null)
|
||||
const isActive = item.status === 'submitting' || item.status === 'running'
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && item.status === 'idle') {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Quality control shown right of the input (hidden when active or completed)
|
||||
const qualityEl = (() => {
|
||||
if (item.status === 'completed' || isActive) return null
|
||||
if (!isVideoSource(item.locator)) return null
|
||||
if (item.probeState === 'probing') {
|
||||
return <span className="capture-quality-probing" aria-label="Checking available qualities">…</span>
|
||||
}
|
||||
if (item.probeState === 'done') {
|
||||
const qualities = item.probeQualities ?? []
|
||||
const hasAudio = item.probeHasAudio ?? false
|
||||
if (qualities.length === 0 && !hasAudio) {
|
||||
return <span className="capture-quality-hint">No media detected</span>
|
||||
}
|
||||
if (qualities.length === 0 && hasAudio) {
|
||||
// Audio-only source: no video tracks, only audio available.
|
||||
// Don't offer "Best quality" — it would request a video format and fail.
|
||||
return (
|
||||
<select
|
||||
className="capture-quality"
|
||||
value="audio"
|
||||
onChange={e => onQualityChange(e.target.value)}
|
||||
aria-label="Video quality"
|
||||
>
|
||||
<option value="audio">Audio only</option>
|
||||
</select>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<select
|
||||
className="capture-quality"
|
||||
value={item.quality || 'best'}
|
||||
onChange={e => onQualityChange(e.target.value)}
|
||||
aria-label="Video quality"
|
||||
>
|
||||
<option value="best">Best quality</option>
|
||||
{qualities.map(q => <option key={q} value={q}>{q}</option>)}
|
||||
{hasAudio && <option value="audio">Audio only</option>}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
return null // probeState === 'idle', debounce not yet fired
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className={`capture-row capture-row--${item.status}`}>
|
||||
<div className="capture-row-main">
|
||||
<CapStatusDot status={item.status} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="capture-input"
|
||||
type="text"
|
||||
placeholder="https://… · yt:ID · tweet:ID · x:ID"
|
||||
value={item.locator}
|
||||
onChange={e => onLocatorChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onSubmit() }}
|
||||
disabled={isActive || item.status === 'completed'}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{qualityEl}
|
||||
{item.status === 'failed' && (
|
||||
<button type="button" className="capture-row-action" onClick={onReset} title="Retry">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13 2.5A7 7 0 1 1 6.5 1"/>
|
||||
<polyline points="6.5 1 4 3.5 6.5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{!isActive && item.status !== 'completed' && item.status !== 'failed' && (
|
||||
<button type="button" className="capture-row-action capture-row-remove" onClick={onRemove} aria-label="Remove">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{item.error && (
|
||||
<p className="capture-row-error">{item.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CapStatusDot({ status }) {
|
||||
if (status === 'submitting' || status === 'running') {
|
||||
return (
|
||||
<span className="cap-dot cap-dot--running" aria-label="Running">
|
||||
<span className="cap-spinner" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === 'completed') {
|
||||
return <span className="cap-dot cap-dot--ok" aria-label="Done">✓</span>
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return <span className="cap-dot cap-dot--err" aria-label="Failed">✕</span>
|
||||
}
|
||||
return <span className="cap-dot cap-dot--idle" aria-hidden="true" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
|
|
@ -20,6 +22,12 @@ function StatusBadge({ status }) {
|
|||
}
|
||||
|
||||
export default function RunsView({ runs }) {
|
||||
const [expanded, setExpanded] = useState(null) // run_uid of the expanded row
|
||||
|
||||
function toggle(uid) {
|
||||
setExpanded(prev => prev === uid ? null : uid)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="runs-view" className="view is-active">
|
||||
<table className="entry-table">
|
||||
|
|
@ -34,16 +42,43 @@ export default function RunsView({ runs }) {
|
|||
</thead>
|
||||
<tbody>
|
||||
{runs.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>No runs yet.</td></tr>
|
||||
) : runs.map((run, i) => (
|
||||
<tr key={i}>
|
||||
<td>{fmtDate(run.started_at)}</td>
|
||||
<td><StatusBadge status={run.status} /></td>
|
||||
<td>{run.requested_count ?? '—'}</td>
|
||||
<td>{run.completed_count ?? '—'}</td>
|
||||
<td>{run.failed_count ?? '—'}</td>
|
||||
<tr>
|
||||
<td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>
|
||||
No runs yet.
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : runs.map(run => {
|
||||
const hasError = run.status === 'failed' && run.error_summary
|
||||
const isExpanded = expanded === run.run_uid
|
||||
return [
|
||||
<tr
|
||||
key={run.run_uid}
|
||||
className={hasError ? 'run-row run-row--failed' : 'run-row'}
|
||||
onClick={hasError ? () => toggle(run.run_uid) : undefined}
|
||||
title={hasError ? (isExpanded ? 'Click to hide error' : 'Click to view error') : undefined}
|
||||
>
|
||||
<td>{fmtDate(run.started_at)}</td>
|
||||
<td>
|
||||
<StatusBadge status={run.status} />
|
||||
{hasError && (
|
||||
<span className="run-expand-hint" aria-hidden="true">
|
||||
{isExpanded ? '▴' : '▾'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{run.requested_count ?? '—'}</td>
|
||||
<td>{run.completed_count ?? '—'}</td>
|
||||
<td>{run.failed_count ?? '—'}</td>
|
||||
</tr>,
|
||||
hasError && isExpanded && (
|
||||
<tr key={`${run.run_uid}-detail`} className="run-error-row">
|
||||
<td colSpan={5}>
|
||||
<pre className="run-error-detail">{run.error_summary}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
]
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
|
|||
63
frontend/src/components/ToastStack.jsx
Normal file
63
frontend/src/components/ToastStack.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
|
||||
const TOAST_TTL = 7000 // ms before auto-dismiss; paused when error is expanded
|
||||
|
||||
export default function ToastStack({ toasts, onDismiss }) {
|
||||
if (!toasts.length) return null
|
||||
return (
|
||||
<div className="toast-stack" role="log" aria-live="polite" aria-label="Notifications">
|
||||
{toasts.map(t => (
|
||||
<Toast key={t.id} toast={t} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toast({ toast, onDismiss }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Auto-dismiss after TTL; paused while error detail is expanded
|
||||
useEffect(() => {
|
||||
if (expanded) return
|
||||
const timer = setTimeout(() => onDismiss(toast.id), TOAST_TTL)
|
||||
return () => clearTimeout(timer)
|
||||
}, [expanded, toast.id, onDismiss])
|
||||
|
||||
const short = toast.locator
|
||||
? (toast.locator.length > 48 ? toast.locator.slice(0, 45) + '\u2026' : toast.locator)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="toast toast--error" role="alert" aria-atomic="true">
|
||||
<div className="toast-top">
|
||||
<span className="toast-icon" aria-hidden="true">✕</span>
|
||||
<div className="toast-body">
|
||||
<span className="toast-headline">Capture failed</span>
|
||||
{short && <span className="toast-locator">{short}</span>}
|
||||
</div>
|
||||
<div className="toast-btns">
|
||||
{toast.errorText && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast-view-btn"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
>
|
||||
{expanded ? 'Hide' : 'View error'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="toast-dismiss"
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && toast.errorText && (
|
||||
<pre className="toast-error-detail">{toast.errorText}</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -557,8 +557,7 @@ select {
|
|||
border: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
padding: 0;
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
width: min(540px, calc(100vw - 32px));
|
||||
border-radius: var(--r3);
|
||||
box-shadow: 0 20px 60px rgba(20, 29, 24, 0.28);
|
||||
}
|
||||
|
|
@ -569,7 +568,7 @@ select {
|
|||
padding: 28px;
|
||||
}
|
||||
.capture-dialog-title {
|
||||
margin: 0 0 16px;
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
|
|
@ -634,6 +633,266 @@ select {
|
|||
.capture-submit:hover { opacity: 0.85; }
|
||||
.capture-submit:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* ── Capture dialog: header + multi-row ─────────────────────────────────── */
|
||||
.capture-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.capture-dialog-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r);
|
||||
padding: 0;
|
||||
}
|
||||
.capture-dialog-close svg { width: 14px; height: 14px; }
|
||||
.capture-dialog-close:hover { background: var(--paper-2); color: var(--ink); }
|
||||
|
||||
.capture-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.capture-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.capture-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
/* No bottom-margin on inputs inside rows; gap handles spacing */
|
||||
.capture-row-main .capture-input { margin-bottom: 0; }
|
||||
|
||||
/* Quality selector shown for yt-dlp sources */
|
||||
.capture-quality {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.capture-quality:hover { border-color: var(--accent-2); }
|
||||
.capture-quality:focus { border-color: var(--accent); }
|
||||
.capture-quality:disabled { opacity: 0.5; cursor: default; }
|
||||
/* Probing state: ellipsis shown while yt-dlp metadata is in flight */
|
||||
.capture-quality-probing {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 0 4px;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
/* "No video detected" note */
|
||||
.capture-quality-hint {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.cap-dot {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.cap-dot--idle { background: var(--paper-2); }
|
||||
.cap-dot--running { background: #fdefd8; color: #8a4f10; }
|
||||
.cap-dot--ok { background: #d8eddf; color: #235c35; }
|
||||
.cap-dot--err { background: #f5ddd8; color: #8d3f30; }
|
||||
|
||||
/* Spinner inside the running dot */
|
||||
.cap-spinner {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: cap-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes cap-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Per-row action buttons (remove / retry) */
|
||||
.capture-row-action {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r);
|
||||
padding: 0;
|
||||
}
|
||||
.capture-row-action svg { width: 12px; height: 12px; }
|
||||
.capture-row-action:hover { color: var(--accent); background: var(--paper-2); }
|
||||
.capture-row-remove:hover { color: var(--accent); }
|
||||
|
||||
/* Inline error under a failed row */
|
||||
.capture-row-error {
|
||||
margin: 0;
|
||||
padding-left: 28px; /* align past the status dot */
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Add-another button */
|
||||
.capture-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--r);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 7px 12px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 18px;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.capture-add-row svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
.capture-add-row:hover { border-color: var(--accent-2); color: var(--ink); background: var(--paper-2); }
|
||||
|
||||
/* ── Toast stack ─────────────────────────────────────────────────────────── */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 340px;
|
||||
max-width: calc(100vw - 32px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r2);
|
||||
box-shadow: 0 6px 24px rgba(20, 29, 24, 0.18), 0 1px 4px rgba(20, 29, 24, 0.1);
|
||||
overflow: hidden;
|
||||
animation: toast-slide-in 0.22s cubic-bezier(.22,.68,0,1.2);
|
||||
}
|
||||
@keyframes toast-slide-in {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.toast--error { border-left: 3px solid var(--accent); }
|
||||
|
||||
.toast-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
}
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.toast-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.toast-headline {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.toast-locator {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toast-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toast-view-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--link);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toast-view-btn:hover { background: var(--paper-2); border-color: var(--link); }
|
||||
.toast-dismiss {
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r);
|
||||
padding: 0;
|
||||
}
|
||||
.toast-dismiss:hover { color: var(--ink); background: var(--paper-2); }
|
||||
.toast-error-detail {
|
||||
margin: 0;
|
||||
padding: 10px 14px 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
background: var(--paper-2);
|
||||
border-top: 1px solid var(--line);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Utility ─────────────────────────────────────────────────────────────── */
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
|
|
@ -1311,6 +1570,32 @@ select {
|
|||
.run-status--failed { background: #f5ddd8; color: #8d3f30; border: 1px solid #e0b8b0; }
|
||||
.run-status--in-progress { background: #fdefd8; color: #8a4f10; border: 1px solid #f0cfa0; }
|
||||
|
||||
/* Clickable failed run rows and expandable error detail */
|
||||
.run-row--failed { cursor: pointer; }
|
||||
.run-row--failed:hover td { background: #fdf0ed !important; }
|
||||
.run-expand-hint {
|
||||
margin-left: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.run-error-row td {
|
||||
padding: 0 !important;
|
||||
background: var(--paper-2) !important;
|
||||
}
|
||||
.run-error-detail {
|
||||
margin: 0;
|
||||
padding: 10px 16px 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Token created banner ────────────────────────────────────────────────── */
|
||||
.token-banner {
|
||||
background: #d8eddf;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue