mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
No commits in common. "b8e496457ff65ab728cc91093c21d02463a95cf9" and "d83faab8e6371f7fb1925de7306ba9e6c568e7a1" have entirely different histories.
b8e496457f
...
d83faab8e6
17 changed files with 255 additions and 1340 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, None)?;
|
||||
let result = archivr_core::capture::perform_capture(&archive_paths, path, None)?;
|
||||
println!("Archived: run {}", result.run_uid);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use uuid::Uuid;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
|
|
@ -331,26 +330,6 @@ 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())
|
||||
|
|
@ -447,6 +426,20 @@ 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)
|
||||
}
|
||||
|
|
@ -683,15 +676,8 @@ pub fn perform_capture(
|
|||
archive_paths: &ArchivePaths,
|
||||
locator: &str,
|
||||
archive_id: Option<&str>,
|
||||
quality: Option<&str>,
|
||||
) -> Result<CaptureResult> {
|
||||
// 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 timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f").to_string();
|
||||
let store_path = &archive_paths.store_path;
|
||||
|
||||
let conn = database::open_or_initialize(&archive_paths.archive_path)?;
|
||||
|
|
@ -699,31 +685,24 @@ pub fn perform_capture(
|
|||
|
||||
let mut source = determine_source(locator);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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) => {
|
||||
// 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));
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("Failed to probe URL: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -1022,7 +1001,7 @@ pub fn perform_capture(
|
|||
_ => None,
|
||||
};
|
||||
|
||||
let (hash, file_extension) = match source {
|
||||
let hash = match source {
|
||||
Source::YouTubeVideo
|
||||
| Source::X
|
||||
| Source::Instagram
|
||||
|
|
@ -1030,8 +1009,8 @@ pub fn perform_capture(
|
|||
| Source::TikTok
|
||||
| Source::Reddit
|
||||
| Source::Snapchat => {
|
||||
match downloader::ytdlp::download(path.clone(), store_path, ×tamp, quality) {
|
||||
Ok(result) => result,
|
||||
match downloader::ytdlp::download(path.clone(), store_path, ×tamp) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return Err(fail_run(
|
||||
&conn,
|
||||
|
|
@ -1044,7 +1023,7 @@ pub fn perform_capture(
|
|||
}
|
||||
Source::Local => {
|
||||
match downloader::local::save(path.clone(), store_path, ×tamp) {
|
||||
Ok(h) => (h, local_file_extension(&path)),
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return Err(fail_run(
|
||||
&conn,
|
||||
|
|
@ -1065,6 +1044,8 @@ pub fn perform_capture(
|
|||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let file_extension = media_file_extension(source, &path);
|
||||
let temp_file = store_path
|
||||
.join("temp")
|
||||
.join(×tamp)
|
||||
|
|
|
|||
|
|
@ -72,16 +72,9 @@ 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,126 +1,26 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use std::{env, path::{Path, PathBuf}, process::Command};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::{env, path::Path, process::Command};
|
||||
|
||||
use crate::hash::hash_file;
|
||||
|
||||
/// 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)> {
|
||||
pub fn download(path: String, store_path: &Path, timestamp: &String) -> Result<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)?;
|
||||
|
||||
// %(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_file = temp_dir.join(format!("{timestamp}.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
|
||||
let out = Command::new(&ytdlp)
|
||||
.arg(&path)
|
||||
.arg("-f")
|
||||
.arg("bestvideo+bestaudio/best")
|
||||
.arg("--merge-output-format")
|
||||
.arg("mp4")
|
||||
.arg("-o")
|
||||
.arg(&out_template)
|
||||
.arg(&out_file)
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn {ytdlp} process"))?;
|
||||
|
||||
|
|
@ -129,31 +29,7 @@ pub fn download(
|
|||
bail!("yt-dlp failed: {stderr}");
|
||||
}
|
||||
|
||||
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()
|
||||
)
|
||||
hash_file(&out_file)
|
||||
}
|
||||
|
||||
/// Fetches metadata JSON for `path` via `yt-dlp --dump-json`.
|
||||
|
|
@ -172,90 +48,10 @@ 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, downloader};
|
||||
use archivr_core::{archive, capture, database};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{ConnectInfo, Path, Query, Request, State},
|
||||
|
|
@ -231,7 +231,6 @@ 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),
|
||||
|
|
@ -638,14 +637,6 @@ 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)]
|
||||
|
|
@ -685,18 +676,6 @@ 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)?;
|
||||
|
|
@ -708,7 +687,6 @@ 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();
|
||||
|
|
@ -721,7 +699,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), quality.as_deref()) {
|
||||
match capture::perform_capture(&archive_paths, &locator, Some(&archive_id_bg)) {
|
||||
Ok(result) => {
|
||||
database::update_capture_job_status(
|
||||
&conn,
|
||||
|
|
@ -764,64 +742,6 @@ 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> {
|
||||
|
|
@ -2286,93 +2206,6 @@ 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();
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-Dhs0pFsV.css
Normal file
1
crates/archivr-server/static/assets/index-Dhs0pFsV.css
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-j6_-GyNh.js
Normal file
40
crates/archivr-server/static/assets/index-j6_-GyNh.js
Normal file
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-BHg5-TAr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BW0QKHXE.css">
|
||||
<script type="module" crossorigin src="/assets/index-j6_-GyNh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dhs0pFsV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -146,35 +146,6 @@ 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
|
||||
|
|
@ -343,9 +314,6 @@ 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,7 +13,6 @@ 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);
|
||||
|
||||
|
|
@ -83,9 +82,6 @@ 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
|
||||
|
|
@ -238,15 +234,6 @@ 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'); }} />;
|
||||
|
|
@ -345,9 +332,7 @@ export default function App() {
|
|||
archiveId={archiveId}
|
||||
onClose={handleCaptureClose}
|
||||
onCaptured={handleCaptured}
|
||||
onToast={handleToast}
|
||||
/>
|
||||
<ToastStack toasts={toasts} onDismiss={handleDismissToast} />
|
||||
</>
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -95,13 +95,11 @@ export async function fetchTags(archiveId) {
|
|||
return getJson(`/api/archives/${archiveId}/tags`);
|
||||
}
|
||||
|
||||
export async function submitCapture(archiveId, locator, quality = null) {
|
||||
const payload = { locator }
|
||||
if (quality && quality !== 'best') payload.quality = quality
|
||||
export async function submitCapture(archiveId, locator) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/captures`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({ locator }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
|
@ -110,12 +108,6 @@ export async function submitCapture(archiveId, locator, quality = null) {
|
|||
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,452 +1,202 @@
|
|||
import { useRef, useEffect, useState } from 'react'
|
||||
import { submitCapture, pollCaptureJob, probeCapture } from '../api'
|
||||
import { submitCapture, pollCaptureJob } from '../api'
|
||||
|
||||
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 }) {
|
||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
|
||||
const dialogRef = useRef(null)
|
||||
const isFirstRenderRef = useRef(true)
|
||||
// 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])
|
||||
|
||||
// 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])
|
||||
|
||||
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
|
||||
}
|
||||
} catch {}
|
||||
return [makeItem()]
|
||||
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)
|
||||
|
||||
// Persist items to sessionStorage on every change
|
||||
// Persist state to sessionStorage
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureItems', JSON.stringify(items))
|
||||
}, [items])
|
||||
sessionStorage.setItem('captureDialogLocator', locator)
|
||||
}, [locator])
|
||||
|
||||
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs
|
||||
useEffect(() => {
|
||||
;['captureDialogLocator','captureDialogError','captureDialogBusy',
|
||||
'captureDialogJobStatus','captureDialogJobUid'].forEach(k => sessionStorage.removeItem(k))
|
||||
sessionStorage.setItem('captureDialogError', error || '')
|
||||
}, [error])
|
||||
|
||||
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
|
||||
))
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureDialogBusy', busy)
|
||||
}, [busy])
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // intentionally runs once on mount with initial values
|
||||
}, 500)
|
||||
}, [jobUid, jobStatus, archiveId, onCaptured])
|
||||
|
||||
// Handle native dialog 'close' event (Escape key or programmatic .close())
|
||||
// Handle dialog close event
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
const handler = () => {
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
probeTimers.current.clear()
|
||||
const handleClose = () => {
|
||||
clearInterval(pollRef.current)
|
||||
onClose()
|
||||
}
|
||||
dialog.addEventListener('close', handler)
|
||||
return () => dialog.removeEventListener('close', handler)
|
||||
dialog.addEventListener('close', handleClose)
|
||||
return () => dialog.removeEventListener('close', handleClose)
|
||||
}, [onClose])
|
||||
|
||||
// Open/close driven by parent; don't reset if active jobs are in flight
|
||||
// Handle open/close from parent
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
|
||||
if (open) {
|
||||
if (!isFirstRenderRef.current && !hasActiveJobs(items)) {
|
||||
setItems([makeItem()])
|
||||
// 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)
|
||||
}
|
||||
isFirstRenderRef.current = false
|
||||
if (!dialog.open) dialog.showModal()
|
||||
} else {
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
probeTimers.current.clear()
|
||||
if (dialog.open) dialog.close()
|
||||
}
|
||||
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
async function handleSubmit() {
|
||||
if (!locator.trim()) { setError('Enter a locator.'); return }
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setJobStatus(null)
|
||||
try {
|
||||
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)
|
||||
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)
|
||||
} catch (e) {
|
||||
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)
|
||||
setError(e.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleArchive() {
|
||||
const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim())
|
||||
toSubmit.forEach(it => submitItem(it))
|
||||
function buttonLabel() {
|
||||
if (!busy) return 'Capture'
|
||||
if (jobStatus === 'running') return 'Running\u2026'
|
||||
return 'Capturing\u2026'
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<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-actions">
|
||||
<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 type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>Cancel</button>
|
||||
<button type="button" className="capture-submit" onClick={handleSubmit} disabled={busy}>
|
||||
{buttonLabel()}
|
||||
</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,5 +1,3 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
|
|
@ -22,12 +20,6 @@ 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">
|
||||
|
|
@ -42,43 +34,16 @@ 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><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>
|
||||
) : 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>
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
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,7 +557,8 @@ select {
|
|||
border: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
padding: 0;
|
||||
width: min(540px, calc(100vw - 32px));
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
border-radius: var(--r3);
|
||||
box-shadow: 0 20px 60px rgba(20, 29, 24, 0.28);
|
||||
}
|
||||
|
|
@ -568,7 +569,7 @@ select {
|
|||
padding: 28px;
|
||||
}
|
||||
.capture-dialog-title {
|
||||
margin: 0;
|
||||
margin: 0 0 16px;
|
||||
font-size: 22px;
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
|
|
@ -633,266 +634,6 @@ 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); }
|
||||
|
||||
|
|
@ -1570,32 +1311,6 @@ 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