1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

feat: share videos to TV (Chromecast + AirPlay) (#33)

* server: add scoped media-token endpoint for Cast/AirPlay auth bypass

Chromecast and Apple TV fetch media URLs as independent HTTP clients
with no session cookie. The existing serve_artifact handler requires
auth_user.require_auth(), so those devices always received 401.

Changes:
- MediaToken struct stored in AppState (Arc<Mutex<HashMap>>), scoped to
  a single (archive_id, entry_uid, artifact_index) tuple with a 2-hour TTL
- POST /api/archives/:id/entries/:uid/artifacts/:idx/media-token
  requires an authenticated session, verifies the artifact exists,
  prunes expired tokens, mints a 43-char URL-safe token, and returns
  { url, expires_in_secs }
- serve_artifact now accepts an optional ?token= query param; a valid
  scoped token bypasses require_auth() while a missing/invalid/expired
  token falls through to the normal 401 path
- CSP script-src extended to include https://www.gstatic.com so the
  Cast sender SDK script (injected lazily by VideoPreview) is not blocked
- 4 new tests: bare-URL still 401, tokenized fetch succeeds without
  session cookie, bogus token 401, wrong-artifact-index 401

* frontend: Cast/AirPlay overlay in VideoPreview

When the user opens a video archive entry, VideoPreview now:

1. Issues a signed media token (POST .../artifacts/:idx/media-token) and
   uses the returned signed URL as <video src>. This ensures the video
   element's src is one that Cast devices and Apple TV can fetch without a
   session cookie.

2. Lazily injects the Google Cast SDK script (cast_sender.js from
   gstatic.com, now allowed by the updated CSP). Once the SDK reports
   available, a <google-cast-launcher> web component appears as an overlay
   button in the top-right corner of the video. Selecting a Cast device
   triggers loadMedia() with the signed URL and the artifact's MIME type.

3. Detects AirPlay support (webkitShowPlaybackTargetPicker on
   HTMLVideoElement) and shows an AirPlay icon button alongside Cast.
   The <video> element carries x-webkit-airplay='allow', so Safari's native
   controls also surface the AirPlay option. The explicit overlay button
   calls webkitShowPlaybackTargetPicker() for consistent placement.

Both buttons are hidden when the respective APIs are unavailable (HTTP
pages, non-Safari for AirPlay, no Cast extension/devices), so there is no
UI regression for users who don't cast.

PreviewPanel now passes contentType (derived from artifact extension) to
VideoPreview so Cast receives a correct MIME type.

New CSS: .video-tv-controls (absolute overlay), .video-tv-btn (frosted
glass icon button), .video-tv-loading (placeholder during token fetch).

* server: fix serve_artifact auth OR logic — bogus token falls back to session

Previously a request carrying ?token=<expired> was immediately rejected
with 401, even if the user held a valid session cookie. This broke
logged-in browser playback after the 2-hour signed-URL window expired,
because VideoPreview uses the signed URL as <video src>.

Fix: compute token_valid first; if the token is absent or invalid, fall
through to auth_user.require_auth() instead of returning early.
Effect: valid token skips session check, invalid/missing token checks
session, both invalid → 401 as before.

Updated the bogus-token-no-session test docstring to clarify it tests
the no-auth path specifically. Added new test:
  media_token_bogus_token_with_session_returns_200 — verifies a logged-in
  user can still fetch the artifact via a URL carrying a stale token.

* frontend: guard token-fetch effect against stale async resolution

A slow issueMediaToken() response for video A could resolve after the
user selected video B and call setSignedSrc(urlA), making the
preview/Cast play the wrong file.

Add a cancelled flag set in the effect cleanup; both .then and .catch
check it before touching state, so only the most recent src wins.

* frontend: load Cast media immediately if session already exists

Previously the effect only sent video to the TV on SESSION_STARTED /
SESSION_RESUMED events. Two gaps:

1. If a Cast session was already active when signedSrc became ready
   (e.g. the SDK resumed a session before the token fetch finished, or
   the user switches videos while already casting), nothing was sent.

2. Same gap if castReady fired after an already-established session.

Fix: extract loadMedia(session) and call it against
ctx.getCurrentSession() immediately when castReady + signedSrc are both
truthy, in addition to keeping the event listener for future connects.

* server: staged file-upload endpoint

POST /api/archives/:id/uploads streams a multipart body to a temp file
under the archive's store/temp/ directory and returns a staged_path the
capture pipeline can move into place.

- Routes: /api/archives/:id/uploads (POST, requires auth)
- Body cap: 10 GiB; chunk-streamed to disk, never buffered in memory
- Path-traversal sanitised on the filename field
- Temp files are cleaned up on error paths (disk-leak fix)
- main.rs wires the new route into the server startup
- Cargo: adds the multipart dependency

* frontend: file upload in Capture dialog

Drag-and-drop or 'Upload file' button stages files for archiving:

- File items sit alongside URL rows in the same list; each shows the
  original filename, a live progress bar during upload, and a check badge
  when ready.  The locator input is replaced entirely — no editable field.
- Archive button is disabled until all uploads finish; each file item
  contributes to the Archive N count once its upload is done.
- File items are excluded from sessionStorage persistence (they are
  transient — the staged server path would be invalid after a reload).

Staged-file cleanup is handled at every exit path so temp/uploads/ does
not accumulate:
  • removeRow on an in-progress item aborts the XHR; removeRow on a done
    item calls DELETE /archives/:id/uploads.
  • Dialog cancel (Escape / Cancel button) aborts all in-flight XHRs and
    DELETEs all completed staged files via the close-event handler.
  • handleArchive sets isSubmittingRef=true before dialog.close() so the
    close handler skips cleanup — the background capture job handles
    staged-file removal on success instead.
  • uploadFile() returns { promise, abort } so the component can cancel
    the XHR without any visible fetch.

api.js additions: uploadFile (XHR with progress + abort), deleteUpload.

(Static assets rebuilt from combined source to include screensharing
changes from this branch.)

* fix: collection enrollment with default_visibility_bits

Two related fixes from feat-file-uploading:

core: fix collection enrollment using default_visibility_bits instead of
entry.visibility — entries were being enrolled with the entry-level
visibility rather than the collection's configured default.

server: allow changing default_visibility_bits on the default collection
— the PATCH handler was incorrectly blocking updates to the default
collection's visibility configuration.

* server: fix unbounded staged-upload disk growth

Two review findings:

P2 — delete staged file on capture failure (routes.rs)
When perform_capture returns Err, the job was marked failed but
staged_upload_path was never removed. With a 10 GiB body cap a few
failed imports could exhaust archive storage before the next restart.
Mirror the success-path cleanup into the Err arm so the file is removed
immediately regardless of outcome.

P1 — periodic staged-upload pruning (main.rs)
The startup prune of temp/uploads/ only ran once, so uploads abandoned
mid-session (browser crash, navigation away) accumulated forever on a
long-running server. Folded the pruning logic into the existing 24 h
maintenance task alongside session cleanup, so stale dirs are swept
continuously without requiring a restart.

* server+frontend: fix staged-upload disk-growth and prune safety

Server (main.rs + routes.rs):

- Extract prune_stale_upload_dirs() helper called by both startup and
  the periodic 24h task, eliminating the duplicated loop.

- Sentinel (.uploading) created in the UUID dir before streaming begins;
  removed on successful completion; error path uses remove_dir_all so
  the partial file and sentinel are cleaned up together.
  The periodic prune skips any dir containing .uploading (active XHR).

- Startup prune passes cleanup_stale_sentinels=true: the server has not
  started accepting connections yet so any sentinel is a crash remnant —
  it is removed and the dir proceeds to the age check, preventing leaked
  dirs from a previous crash accumulating forever.

- Staleness measured from the newest non-sentinel child file mtime so a
  just-finished slow upload (dir mtime stale, file mtime fresh) is not
  pruned before the user can submit it for capture. Empty dirs fall back
  to dir mtime.

- Failed captures (Err branch in spawn_blocking) now also delete the
  staged file and UUID dir immediately, matching the success path.

Frontend (api.js + CaptureDialog.jsx):

- submitCapture attaches err.status = res.status on non-2xx responses
  so callers can distinguish a definite HTTP rejection from a network
  error where the response may have been lost.

- submitBgJob catch deletes the staged file only when e.status is set
  (server definitively rejected the POST /captures request). A network
  error leaves the file in place because the server may have accepted
  the job and the response was lost — deleting would race the capture.
This commit is contained in:
TheGeneralist 2026-07-23 20:12:22 +02:00 • committed by GitHub
parent 6377daadae
commit 1af920eb63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1498 additions and 142 deletions

2
Cargo.lock generated
View file

@ -142,6 +142,7 @@ dependencies = [
"toml",
"tower",
"tower-http",
"uuid",
]
[[package]]
@ -198,6 +199,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",

View file

@ -12,7 +12,7 @@ edition = "2024"
[workspace.dependencies]
anyhow = "1.0.100"
axum = "0.7.9"
axum = { version = "0.7.9", features = ["multipart"] }
chrono = "0.4.42"
clap = { version = "4.5.48", features = ["derive"] }
hex = "0.4.3"
@ -22,7 +22,7 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.132"
sha3 = "0.10.8"
tempfile = "3.13.0"
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] }
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net", "fs", "io-util"] }
toml = "0.8.19"
tower = "0.5.1"
tower-http = { version = "0.6.2", features = ["fs", "trace"] }

View file

@ -1866,9 +1866,24 @@ pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<Arch
)?;
}
// Auto-enroll in the default collection with appropriate visibility_bits.
// Auto-enroll in the default collection.
// Root entries: use the collection's configured default_visibility_bits so the
// archive owner's visibility setting is respected — capture always passes
// "private" (visibility_to_bits → 0) as a safe fallback regardless of intent.
// Child entries (parent_entry_id IS NOT NULL): keep visibility_to_bits(entry.visibility)
// so they inherit the private/unlisted bits set by the parent capture path;
// list_child_entries_inherits_parent_visibility documents this contract.
let default_coll_id = ensure_default_collection(conn)?;
let vbits = visibility_to_bits(&entry.visibility);
let vbits: u32 = if entry.parent_entry_id.is_none() {
conn.query_row(
"SELECT default_visibility_bits FROM collections WHERE id = ?1",
[default_coll_id],
|row| row.get::<_, i64>(0).map(|v| v as u32),
)
.unwrap_or(0)
} else {
visibility_to_bits(&entry.visibility)
};
add_entry_to_collection(conn, default_coll_id, id, vbits)?;
Ok(ArchivedEntry {
@ -2311,6 +2326,7 @@ pub fn visibility_to_bits(visibility: &str) -> u32 {
}
}
/// Returns the id of the '_default_' collection, creating it if absent.
pub fn ensure_default_collection(conn: &Connection) -> Result<i64> {
let now = now_timestamp();
@ -2466,7 +2482,8 @@ pub fn get_entry_collection_memberships(
/// Renames a collection and/or updates its default_visibility_bits.
/// Returns true if updated, false if not found.
/// Refuses to rename the '_default_' collection.
/// Refuses to rename the '_default_' collection but allows changing its
/// default_visibility_bits so users can control the visibility of new captures.
pub fn update_collection(
conn: &Connection,
collection_uid: &str,
@ -2475,8 +2492,8 @@ pub fn update_collection(
) -> Result<bool> {
let coll = get_collection_by_uid(conn, collection_uid)?;
let Some(coll) = coll else { return Ok(false) };
if coll.slug == "_default_" {
anyhow::bail!("cannot modify the default collection");
if coll.slug == "_default_" && new_name.is_some() {
anyhow::bail!("cannot rename the default collection");
}
let name = new_name.unwrap_or(&coll.name);
let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits);
@ -2636,6 +2653,7 @@ pub fn delete_entry_artifacts(conn: &Connection, entry_id: i64) -> Result<usize>
#[cfg(test)]
mod tests {
use super::*;
use crate::archive;
use std::{
env, fs,
time::{SystemTime, UNIX_EPOCH},
@ -2930,6 +2948,121 @@ mod tests {
assert_eq!(public_index_entry_count(&conn).unwrap(), 1);
}
#[test]
fn default_collection_allows_visibility_change_but_not_rename() {
let conn = conn();
let coll_uid = {
let id = ensure_default_collection(&conn).unwrap();
conn.query_row(
"SELECT collection_uid FROM collections WHERE id = ?1",
[id],
|row| row.get::<_, String>(0),
)
.unwrap()
};
// Changing default_visibility_bits on _default_ must succeed.
let updated = update_collection(&conn, &coll_uid, None, Some(3)).unwrap();
assert!(updated, "visibility change on _default_ should succeed");
let bits: u32 = conn
.query_row(
"SELECT default_visibility_bits FROM collections WHERE collection_uid = ?1",
[&coll_uid],
|row| row.get::<_, i64>(0).map(|v| v as u32),
)
.unwrap();
assert_eq!(bits, 3, "default_visibility_bits should be updated to 3");
// Renaming _default_ must still be rejected.
let err = update_collection(&conn, &coll_uid, Some("My Archive"), None);
assert!(err.is_err(), "renaming _default_ must be rejected");
assert!(
err.unwrap_err().to_string().contains("cannot rename"),
"error must mention rename"
);
}
#[test]
fn default_collection_visibility_governs_enrollment_not_entry_visibility() {
// Regression: capture hardcodes entry.visibility = "private", but
// collection_entries.visibility_bits must come from the collection's
// default_visibility_bits so that non-admin users can see new entries.
let conn = conn();
// Confirm the default collection starts with default_visibility_bits = 2
// (USER-only / "unlisted").
let default_id = ensure_default_collection(&conn).unwrap();
let default_bits: u32 = conn
.query_row(
"SELECT default_visibility_bits FROM collections WHERE id = ?1",
[default_id],
|row| row.get::<_, i64>(0).map(|v| v as u32),
)
.unwrap();
assert_eq!(default_bits, 2, "default collection should start USER-visible");
// Create an entry with visibility = "private" (what capture always passes).
let entry = create_entry_fixture(&conn, "private", None, None);
// The archived_entries row must keep the caller's value ("private").
let stored_vis: String = conn
.query_row(
"SELECT visibility FROM archived_entries WHERE id = ?1",
[entry.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(stored_vis, "private");
// But the collection_entries enrollment must use default_visibility_bits (2),
// not visibility_to_bits("private") (0) — otherwise the entry is invisible.
let enrolled_bits: u32 = conn
.query_row(
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
[entry.id],
|row| row.get::<_, i64>(0).map(|v| v as u32),
)
.unwrap();
assert_eq!(
enrolled_bits, 2,
"collection_entries.visibility_bits must come from the collection default, not entry.visibility"
);
// A USER caller (role_bits = 2) must now see the entry.
let visible = archive::list_root_entries(&conn, 2).unwrap();
assert_eq!(visible.len(), 1, "USER should see the entry after fix");
// An explicit public entry must still enroll at bits = 3 when the
// collection default is changed to public.
conn.execute(
"UPDATE collections SET default_visibility_bits = 3 WHERE id = ?1",
[default_id],
)
.unwrap();
let public_entry = create_entry_fixture(&conn, "public", None, None);
let public_bits: u32 = conn
.query_row(
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
[public_entry.id],
|row| row.get::<_, i64>(0).map(|v| v as u32),
)
.unwrap();
assert_eq!(public_bits, 3, "collection default=public should produce bits=3");
// Child entries must NOT use the collection default — they keep
// visibility_to_bits(entry.visibility) so parent-child visibility
// inheritance is not broken (list_child_entries_inherits_parent_visibility).
let child = create_entry_fixture(&conn, "private", Some(entry.id), Some(entry.id));
let child_bits: u32 = conn
.query_row(
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
[child.id],
|row| row.get::<_, i64>(0).map(|v| v as u32),
)
.unwrap();
assert_eq!(child_bits, 0, "child entries must use visibility_to_bits, not collection default");
}
#[test]
fn hierarchical_tag_assignments_are_discoverable_through_ancestors() {
let conn = conn();

View file

@ -22,6 +22,7 @@ rusqlite.workspace = true
parking_lot.workspace = true
regex.workspace = true
reqwest.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -7,6 +7,64 @@ use std::{net::SocketAddr, path::PathBuf};
const DEFAULT_BIND: &str = "127.0.0.1:8080";
/// Prune abandoned staged-upload UUID dirs under `uploads_dir` whose content
/// is older than `cutoff`.
///
/// `cleanup_stale_sentinels`: pass `true` at startup (before the server begins
/// accepting connections) so crash-leftover `.uploading` markers are removed and
/// those dirs are subject to the normal age check. Pass `false` from the
/// in-process periodic task so dirs with a live sentinel (active XHR) are
/// skipped entirely.
///
/// Staleness is measured from the newest non-sentinel child file's mtime so a
/// just-finished slow upload is not pruned before the user submits it for
/// capture. Empty dirs fall back to the directory mtime.
fn prune_stale_upload_dirs(
uploads_dir: &std::path::Path,
cutoff: std::time::SystemTime,
cleanup_stale_sentinels: bool,
) {
let Ok(entries) = std::fs::read_dir(uploads_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let sentinel = path.join(".uploading");
if sentinel.exists() {
if cleanup_stale_sentinels {
// Server just started — no uploads are in flight, so any
// sentinel is a crash remnant. Remove it and fall through
// to the age check below.
let _ = std::fs::remove_file(&sentinel);
} else {
// An active XHR is writing to this dir — leave it alone.
continue;
}
}
// Measure staleness from the newest non-sentinel child file so a
// completed slow upload isn't pruned while the user is still on the
// capture form. Fall back to dir mtime only when the dir is empty.
let newest_child = std::fs::read_dir(&path)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.file_name() != std::ffi::OsStr::new(".uploading"))
.filter_map(|e| e.metadata().ok())
.filter_map(|m| m.modified().ok())
.max();
let reference = newest_child
.or_else(|| entry.metadata().and_then(|m| m.modified()).ok())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
if reference < cutoff {
let _ = std::fs::remove_dir_all(&path);
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let config_path = std::env::args()
@ -30,15 +88,34 @@ async fn main() -> Result<()> {
for archive in &registry.archives {
if let Ok(conn) = archivr_core::database::open_or_initialize(&archive.archive_path) {
match archivr_core::database::fail_stalled_capture_jobs(&conn) {
Ok(n) if n > 0 => eprintln!("info: marked {n} stalled capture job(s) as failed in '{}'", archive.id),
Err(e) => eprintln!("warn: stalled job cleanup failed for '{}': {e:#}", archive.id),
Ok(n) if n > 0 => eprintln!(
"info: marked {n} stalled capture job(s) as failed in '{}'",
archive.id
),
Err(e) => eprintln!(
"warn: stalled job cleanup failed for '{}': {e:#}",
archive.id
),
_ => {}
}
}
}
// Spawn session cleanup: runs at startup and every 24h.
// Prune staged upload dirs older than 24 h. cleanup_stale_sentinels=true
// because no uploads are in flight before the server starts listening.
let prune_cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(24 * 60 * 60))
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
for archive in &registry.archives {
if let Ok(paths) = archivr_core::archive::read_archive_paths(&archive.archive_path) {
let uploads_dir = paths.store_path.join("temp").join("uploads");
prune_stale_upload_dirs(&uploads_dir, prune_cutoff, true);
}
}
// Spawn maintenance task: session cleanup + staged-upload pruning, every 24 h.
let cleanup_auth_path = auth_db_path.clone();
let cleanup_registry = registry.clone();
tokio::spawn(async move {
loop {
if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) {
@ -48,6 +125,17 @@ async fn main() -> Result<()> {
_ => {}
}
}
// cleanup_stale_sentinels=false: server is live, respect active uploads.
let prune_cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(24 * 60 * 60))
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
for archive in &cleanup_registry.archives {
if let Ok(paths) = archivr_core::archive::read_archive_paths(&archive.archive_path)
{
let uploads_dir = paths.store_path.join("temp").join("uploads");
prune_stale_upload_dirs(&uploads_dir, prune_cutoff, false);
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await;
}
});
@ -63,6 +151,10 @@ async fn main() -> Result<()> {
let listener = tokio::net::TcpListener::bind(addr).await?;
println!("archivr-server listening on http://{addr}");
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}

View file

@ -32,7 +32,7 @@ use std::{
use archivr_core::{archive, capture, database, downloader};
use axum::{
Json, Router,
extract::{ConnectInfo, Path, Query, Request, State},
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
@ -50,11 +50,21 @@ use rusqlite::OptionalExtension;
const LOGIN_WINDOW: Duration = Duration::from_secs(15 * 60);
const LOGIN_MAX_ATTEMPTS: usize = 5;
// Short-lived token granting unauthenticated access to one specific artifact.
// Used so Cast / AirPlay devices (which carry no session cookie) can fetch media.
pub(crate) struct MediaToken {
archive_id: String,
entry_uid: String,
artifact_index: usize,
expires_at: std::time::Instant,
}
#[derive(Clone)]
pub struct AppState {
registry: Arc<ServerRegistry>,
pub auth_db_path: Arc<std::path::PathBuf>,
pub login_attempts: Arc<Mutex<HashMap<IpAddr, VecDeque<Instant>>>>,
pub media_tokens: Arc<Mutex<HashMap<String, MediaToken>>>,
}
#[derive(Debug, serde::Deserialize, Default)]
@ -133,7 +143,7 @@ async fn security_headers(req: Request, next: Next) -> Response {
axum::http::header::HeaderName::from_static("content-security-policy"),
axum::http::HeaderValue::from_static(
"default-src 'self'; \
script-src 'self'; \
script-src 'self' https://www.gstatic.com; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
img-src 'self' data: blob: https:; \
font-src 'self' https://fonts.gstatic.com; \
@ -249,6 +259,10 @@ pub fn app_with_state(state: AppState) -> Router {
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
get(serve_artifact),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index/media-token",
post(issue_media_token),
)
.route(
"/api/archives/:archive_id/entries/:entry_uid/rearchive",
post(rearchive_handler),
@ -260,6 +274,12 @@ pub fn app_with_state(state: AppState) -> Router {
.route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob))
.route("/api/archives/:archive_id/runs", get(list_runs))
.route("/api/archives/:archive_id/captures", post(capture_handler))
.route(
"/api/archives/:archive_id/uploads",
post(upload_handler)
.delete(delete_upload_handler)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)),
)
.route(
"/api/archives/:archive_id/captures/probe",
get(probe_handler),
@ -386,6 +406,7 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
registry: Arc::new(registry),
auth_db_path: Arc::new(auth_db_path),
login_attempts: Arc::new(Mutex::new(HashMap::new())),
media_tokens: Arc::new(Mutex::new(HashMap::new())),
};
app_with_state(state)
}
@ -421,7 +442,11 @@ async fn list_entry_children(
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
let caller_bits = auth_to_caller_bits(&auth);
Ok(Json(archive::list_child_entries(&conn, &entry_uid, caller_bits)?))
Ok(Json(archive::list_child_entries(
&conn,
&entry_uid,
caller_bits,
)?))
}
async fn search_entries_handler(
@ -466,14 +491,41 @@ async fn list_runs(
let conn = database::open_or_initialize(&mounted.archive_path)?;
Ok(Json(archive::list_runs(&conn)?))
}
const MEDIA_TOKEN_TTL: Duration = Duration::from_secs(2 * 60 * 60); // 2 h
#[derive(Debug, serde::Deserialize, Default)]
struct ArtifactQuery {
token: Option<String>,
}
#[derive(serde::Serialize)]
struct MediaTokenResponse {
url: String,
expires_in_secs: u64,
}
async fn serve_artifact(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>,
Query(params): Query<ArtifactQuery>,
req: Request,
) -> Result<Response, ApiError> {
// Auth: valid scoped token OR authenticated session (OR both).
// A token present but invalid/expired falls back to session auth so that
// a logged-in browser player keeps working after a token expires.
let token_valid = params.token.as_deref().map_or(false, |tok| {
let tokens = state.media_tokens.lock();
tokens.get(tok).map_or(false, |t| {
t.archive_id == archive_id
&& t.entry_uid == entry_uid
&& t.artifact_index == artifact_index
&& t.expires_at > std::time::Instant::now()
})
});
if !token_valid {
auth_user.require_auth()?;
}
let mounted = mounted_archive(&state, &archive_id)?;
let paths = archive::read_archive_paths(&mounted.archive_path)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
@ -491,6 +543,51 @@ async fn serve_artifact(
.into_response())
}
/// POST /api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index/media-token
///
/// Requires an authenticated session. Returns a short-lived signed URL that
/// allows unauthenticated GET of the specified artifact — intended for Cast /
/// AirPlay devices that cannot carry the browser's session cookie.
async fn issue_media_token(
State(state): State<AppState>,
auth_user: AuthUser,
Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>,
) -> Result<Json<MediaTokenResponse>, ApiError> {
auth_user.require_auth()?;
// Verify the artifact actually exists before issuing a token.
let mounted = mounted_archive(&state, &archive_id)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
let detail = archive::get_entry_detail(&conn, &entry_uid)?
.ok_or(ApiError::not_found("entry not found"))?;
if artifact_index >= detail.artifacts.len() {
return Err(ApiError::not_found("artifact index out of range"));
}
let token = auth::generate_token();
let now = std::time::Instant::now();
{
let mut tokens = state.media_tokens.lock();
// GC expired tokens on each issuance to keep the map bounded.
tokens.retain(|_, t| t.expires_at > now);
tokens.insert(
token.clone(),
MediaToken {
archive_id: archive_id.clone(),
entry_uid: entry_uid.clone(),
artifact_index,
expires_at: now + MEDIA_TOKEN_TTL,
},
);
}
let url = format!(
"/api/archives/{}/entries/{}/artifacts/{}?token={}",
archive_id, entry_uid, artifact_index, token
);
Ok(Json(MediaTokenResponse {
url,
expires_in_secs: MEDIA_TOKEN_TTL.as_secs(),
}))
}
async fn serve_entry_favicon(
State(state): State<AppState>,
auth_user: AuthUser,
@ -836,6 +933,11 @@ struct UpdateCookieRuleBody {
ordinal: Option<i64>,
}
#[derive(Debug, serde::Deserialize)]
struct DeleteUploadBody {
locator: String,
}
async fn capture_handler(
State(state): State<AppState>,
auth_user: AuthUser,
@ -866,7 +968,11 @@ async fn capture_handler(
.and_then(|n| n.parse::<u32>().ok())
.is_some()
};
if let Some(bad) = body.per_item_quality.values().find(|q| !is_valid_quality(q)) {
if let Some(bad) = body
.per_item_quality
.values()
.find(|q| !is_valid_quality(q))
{
return Err(ApiError::bad_request(&format!(
"invalid per_item_quality value {bad:?}: must be \"best\", \"audio\", or a height string like \"1080p\""
)));
@ -923,6 +1029,24 @@ async fn capture_handler(
// Spawn background capture.
let locator = body.locator.trim().to_string();
// If the locator is a file:// path staged under temp/uploads/, track it for cleanup.
// Canonicalize both sides to prevent path-traversal via `..` components in the locator.
// Same pattern as artifact serving (line ~631). The staged file must already exist on disk
// (it was written by upload_handler), so canonicalize() will resolve symlinks correctly.
let staged_upload_path: Option<std::path::PathBuf> = if locator.starts_with("file://") {
let file_path = std::path::PathBuf::from(locator.trim_start_matches("file://"));
let staging_dir = archive_paths.store_path.join("temp").join("uploads");
match (file_path.canonicalize(), staging_dir.canonicalize()) {
(Ok(canonical_file), Ok(canonical_staging))
if canonical_file.starts_with(&canonical_staging) =>
{
Some(canonical_file)
}
_ => None,
}
} else {
None
};
let quality = body.quality.clone();
let archive_path = mounted.archive_path.clone();
let job_uid_bg = job_uid.clone();
@ -976,6 +1100,16 @@ async fn capture_handler(
notes,
)
.ok();
// Clean up staged upload file — content is now in the raw store.
// `staged` is already the canonicalized path (safe to remove_file directly).
// Also attempt to remove the now-empty UUID parent dir; fails silently if
// non-empty or already gone.
if let Some(staged) = staged_upload_path {
let _ = std::fs::remove_file(&staged);
if let Some(parent) = staged.parent() {
let _ = std::fs::remove_dir(parent);
}
}
}
Err(e) => {
database::update_capture_job_status(
@ -987,6 +1121,15 @@ async fn capture_handler(
None,
)
.ok();
// Failed captures never move the file into the raw store,
// so clean up the staged upload here rather than waiting
// for the next startup or periodic prune.
if let Some(staged) = staged_upload_path {
let _ = std::fs::remove_file(&staged);
if let Some(parent) = staged.parent() {
let _ = std::fs::remove_dir(parent);
}
}
}
}
});
@ -997,6 +1140,155 @@ async fn capture_handler(
))
}
async fn upload_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path(archive_id): Path<String>,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
auth_user.require_role(ROLE_USER)?;
let mounted = mounted_archive(&state, &archive_id)?;
let archive_paths =
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
// Stage under temp/uploads/<uuid>/<safe_name> so Source::Local derives the
// entry title from Path::file_name() of the locator rather than the uuid.
let staging_base = archive_paths.store_path.join("temp").join("uploads");
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?
{
if field.name() != Some("file") {
continue;
}
let filename = field.file_name().unwrap_or("upload").to_string();
// Sanitize: strip path separators and control chars, truncate to 200 chars.
let safe_name: String = filename
.chars()
.filter(|c| !matches!(*c, '/' | '\\' | '\0'))
.collect::<String>()
.trim()
.to_string();
let safe_name = if safe_name.is_empty() {
"upload".to_string()
} else {
safe_name.chars().take(200).collect()
};
let uuid_dir = staging_base.join(uuid::Uuid::new_v4().simple().to_string());
tokio::fs::create_dir_all(&uuid_dir).await.map_err(|e| {
ApiError::from(anyhow::anyhow!("failed to create upload staging dir: {e}"))
})?;
// Sentinel: exists while the XHR is streaming. The prune task skips any
// uuid_dir that contains this file, so a slow upload is never deleted
// mid-transfer regardless of wall-clock age.
let sentinel = uuid_dir.join(".uploading");
tokio::fs::File::create(&sentinel).await.map_err(|e| {
ApiError::from(anyhow::anyhow!("failed to create upload sentinel: {e}"))
})?;
let staged_path = uuid_dir.join(&safe_name);
// Stream chunks directly to disk — never buffers the full file in RAM.
// The body limit (10 GiB) is enforced by the DefaultBodyLimit layer.
let stream_result: Result<(), ApiError> = async {
use tokio::io::AsyncWriteExt as _;
let file = tokio::fs::File::create(&staged_path).await.map_err(|e| {
ApiError::from(anyhow::anyhow!("failed to create staged file: {e}"))
})?;
let mut writer = tokio::io::BufWriter::new(file);
while let Some(chunk) = field
.chunk()
.await
.map_err(|e| ApiError::bad_request(&e.to_string()))?
{
writer
.write_all(&chunk)
.await
.map_err(|e| ApiError::from(anyhow::anyhow!("failed to write chunk: {e}")))?;
}
writer
.flush()
.await
.map_err(|e| ApiError::from(anyhow::anyhow!("failed to flush upload: {e}")))?;
Ok(())
}
.await;
if let Err(e) = stream_result {
// remove_dir_all cleans up the partial file and the sentinel together.
let _ = tokio::fs::remove_dir_all(&uuid_dir).await;
return Err(e);
}
// Stream complete — drop the sentinel so the prune task can reclaim the
// dir if it is later abandoned without being submitted for capture.
let _ = tokio::fs::remove_file(&sentinel).await;
let size = tokio::fs::metadata(&staged_path)
.await
.map(|m| m.len() as i64)
.unwrap_or(0);
let locator = format!("file://{}", staged_path.display());
return Ok((
StatusCode::OK,
Json(serde_json::json!({
"locator": locator,
"filename": filename,
"size": size,
})),
));
}
Err(ApiError::bad_request(
"no file field found in multipart upload",
))
}
/// `DELETE /api/archives/:archive_id/uploads`
///
/// Discards a staged upload file that was never submitted for capture —
/// called by the frontend when the user removes a file row or cancels the
/// dialog. The same canonicalize-then-prefix-check used in `capture_handler`
/// prevents path traversal via crafted `file://` locators.
async fn delete_upload_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path(archive_id): Path<String>,
Json(body): Json<DeleteUploadBody>,
) -> Result<StatusCode, ApiError> {
auth_user.require_role(ROLE_USER)?;
let mounted = mounted_archive(&state, &archive_id)?;
let archive_paths =
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
if !body.locator.starts_with("file://") {
return Err(ApiError::bad_request("locator must be a file:// URI"));
}
let file_path = std::path::PathBuf::from(body.locator.trim_start_matches("file://"));
let staging_dir = archive_paths.store_path.join("temp").join("uploads");
// Canonicalize both sides before the prefix check (path-traversal guard).
let (canonical_file, canonical_staging) =
match (file_path.canonicalize(), staging_dir.canonicalize()) {
(Ok(f), Ok(s)) => (f, s),
_ => return Err(ApiError::not_found("staged upload not found")),
};
if !canonical_file.starts_with(&canonical_staging) {
return Err(ApiError::bad_request("locator is not a staged upload"));
}
tokio::fs::remove_file(&canonical_file).await.ok();
if let Some(parent) = canonical_file.parent() {
tokio::fs::remove_dir(parent).await.ok(); // no-op if non-empty
}
Ok(StatusCode::NO_CONTENT)
}
async fn get_capture_job_handler(
State(state): State<AppState>,
auth_user: AuthUser,
@ -1188,8 +1480,11 @@ async fn probe_playlist_handler(
return Err(ApiError::bad_request("locator must not be empty"));
}
// Validate it's a playlist/channel source and expand shorthands.
let canonical_url = capture::locator_to_playlist_url(&locator)
.ok_or_else(|| ApiError::bad_request("locator is not a YouTube playlist, channel, YTM playlist, or Spotify album/playlist"))?;
let canonical_url = capture::locator_to_playlist_url(&locator).ok_or_else(|| {
ApiError::bad_request(
"locator is not a YouTube playlist, channel, YTM playlist, or Spotify album/playlist",
)
})?;
// Verify archive exists.
let _ = mounted_archive(&state, &archive_id)?;
// Resolve cookies.
@ -3758,6 +4053,7 @@ mod tests {
}),
auth_db_path: Arc::new(auth_path),
login_attempts: Arc::new(Mutex::new(HashMap::new())),
media_tokens: Arc::new(Mutex::new(HashMap::new())),
};
let bad_creds = serde_json::json!({ "username": "nobody", "password": "wrong" });
for _ in 0..LOGIN_MAX_ATTEMPTS {
@ -3821,6 +4117,7 @@ mod tests {
}),
auth_db_path: Arc::new(auth_path),
login_attempts: Arc::new(Mutex::new(HashMap::new())),
media_tokens: Arc::new(Mutex::new(HashMap::new())),
};
let bad_creds = serde_json::json!({ "username": "x", "password": "y" });
for _ in 0..LOGIN_MAX_ATTEMPTS {
@ -4589,6 +4886,7 @@ mod tests {
}),
auth_db_path: Arc::new(auth_path.clone()),
login_attempts: Arc::new(Mutex::new(HashMap::new())),
media_tokens: Arc::new(Mutex::new(HashMap::new())),
};
let session_cookie = make_test_session(&auth_path);
@ -4887,4 +5185,237 @@ mod tests {
"extra disk-only file must be deleted"
);
}
// ── Media token tests ────────────────────────────────────────────────────
// Helper: build a minimal archive + auth setup and return (state, entry_uid, session_cookie).
async fn make_media_token_state(
dir: &tempfile::TempDir,
) -> (AppState, String, std::path::PathBuf, String) {
let store_path = dir.path().join("store");
let paths =
archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false)
.unwrap();
// Write artifact file.
let artifact_relpath = "raw/m/e/video.mp4";
let artifact_dir = store_path.join("raw").join("m").join("e");
std::fs::create_dir_all(&artifact_dir).unwrap();
std::fs::write(artifact_dir.join("video.mp4"), b"fakevideo").unwrap();
// Populate DB.
let conn = database::open_or_initialize(&paths.archive_path).unwrap();
let user_id = database::ensure_default_user(&conn).unwrap();
let sid = database::upsert_source_identity(
&conn,
"yt",
"video",
Some("media-token-test"),
Some("https://yt.example/v"),
"https://yt.example/v",
)
.unwrap();
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
let entry = database::create_archived_entry(
&conn,
&database::NewEntry {
source_identity_id: sid,
archive_run_id: run.id,
parent_entry_id: None,
root_entry_id: None,
created_by_user_id: user_id,
owned_by_user_id: user_id,
source_kind: "yt".to_string(),
entity_kind: "video".to_string(),
title: Some("Test Video".to_string()),
visibility: "private".to_string(),
representation_kind: "video".to_string(),
source_metadata_json: "{}".to_string(),
display_metadata_json: None,
},
)
.unwrap();
let blob_id = database::upsert_blob(
&conn,
&database::BlobRecord {
sha256: "bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444aaaa1111"
.to_string(),
byte_size: 9,
mime_type: Some("video/mp4".to_string()),
extension: Some("mp4".to_string()),
raw_relpath: artifact_relpath.to_string(),
},
)
.unwrap();
database::add_entry_artifact(
&conn,
&database::NewArtifact {
entry_id: entry.id,
artifact_role: "primary_media".to_string(),
storage_area: "raw".to_string(),
relpath: artifact_relpath.to_string(),
blob_id: Some(blob_id),
logical_path: None,
metadata_json: None,
},
)
.unwrap();
drop(conn);
let auth_path = dir.path().join("auth.sqlite");
{
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
}
let session_cookie = make_test_session(&auth_path);
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "test".to_string(),
label: "Test".to_string(),
archive_path: paths.archive_path.clone(),
}],
bind: None,
auth_db_path: None,
};
let state = AppState {
registry: Arc::new(registry),
auth_db_path: Arc::new(auth_path),
login_attempts: Arc::new(Mutex::new(HashMap::new())),
media_tokens: Arc::new(Mutex::new(HashMap::new())),
};
(state, entry.entry_uid, paths.archive_path, session_cookie)
}
/// Bare artifact URL (no token, no session) must still return 401.
#[tokio::test]
async fn media_token_bare_artifact_without_auth_returns_401() {
let dir = tempfile::tempdir().unwrap();
let (state, entry_uid, _, _) = make_media_token_state(&dir).await;
let uri = format!("/api/archives/test/entries/{}/artifacts/0", entry_uid);
let response = app_with_state(state)
.oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
/// Authenticated POST to media-token, then unauthenticated GET with token → 200.
#[tokio::test]
async fn media_token_tokenized_artifact_succeeds_unauthenticated() {
let dir = tempfile::tempdir().unwrap();
let (state, entry_uid, _, session_cookie) = make_media_token_state(&dir).await;
// Issue token (authenticated).
let token_uri = format!(
"/api/archives/test/entries/{}/artifacts/0/media-token",
entry_uid
);
let token_resp = app_with_state(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(&token_uri)
.header("cookie", &session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(token_resp.status(), StatusCode::OK);
let body = body_json(token_resp).await;
let signed_url = body["url"].as_str().expect("url field missing");
assert!(body["expires_in_secs"].as_u64().unwrap() > 0);
// Fetch artifact with signed URL — no session cookie.
let artifact_resp = app_with_state(state)
.oneshot(
Request::builder()
.uri(signed_url)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(artifact_resp.status(), StatusCode::OK);
}
/// A bogus token with NO session must return 401 (no valid auth path).
#[tokio::test]
async fn media_token_invalid_token_returns_401() {
let dir = tempfile::tempdir().unwrap();
let (state, entry_uid, _, _) = make_media_token_state(&dir).await;
let uri = format!(
"/api/archives/test/entries/{}/artifacts/0?token=not-a-real-token",
entry_uid
);
let response = app_with_state(state)
.oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
/// A bogus token WITH a valid session must return 200 — the session fallback
/// keeps a logged-in browser player working after a token expires.
#[tokio::test]
async fn media_token_bogus_token_with_session_returns_200() {
let dir = tempfile::tempdir().unwrap();
let (state, entry_uid, _, session_cookie) = make_media_token_state(&dir).await;
let uri = format!(
"/api/archives/test/entries/{}/artifacts/0?token=not-a-real-token",
entry_uid
);
let response = app_with_state(state)
.oneshot(
Request::builder()
.uri(&uri)
.header("cookie", &session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
/// A token issued for artifact 0 must not unlock artifact 1.
#[tokio::test]
async fn media_token_wrong_artifact_index_returns_401() {
let dir = tempfile::tempdir().unwrap();
let (state, entry_uid, _, session_cookie) = make_media_token_state(&dir).await;
// Issue token for artifact 0.
let token_uri = format!(
"/api/archives/test/entries/{}/artifacts/0/media-token",
entry_uid
);
let token_resp = app_with_state(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(&token_uri)
.header("cookie", &session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(token_resp.status(), StatusCode::OK);
let body = body_json(token_resp).await;
let token = body["url"]
.as_str()
.unwrap()
.split("token=")
.nth(1)
.unwrap();
// Try to use it for artifact 1.
let wrong_uri = format!(
"/api/archives/test/entries/{}/artifacts/1?token={}",
entry_uid, token
);
let response = app_with_state(state)
.oneshot(
Request::builder()
.uri(&wrong_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}

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

File diff suppressed because one or more lines are too long

View file

@ -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-De3b80Fv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D8ic-z4p.css">
<script type="module" crossorigin src="/assets/index-D_BVhPjQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-5jBL9-i7.css">
</head>
<body>
<div id="root"></div>

View file

@ -163,7 +163,9 @@ export async function submitCapture(archiveId, locator, quality = null, extensio
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
const err = new Error(body.error || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json(); // { job_uid, status: "pending" }
}
@ -448,6 +450,19 @@ export async function rearchiveEntry(archiveId, entryUid) {
return res.json() // { job_uid, status: 'pending' }
}
// ── Media token ────────────────────────────────────────────────────────────────
// Issues a short-lived signed URL for one artifact — used so Cast / AirPlay
// devices (no session cookie) can fetch the media file directly.
// Returns { url: string, expires_in_secs: number }.
export async function issueMediaToken(archiveId, entryUid, artifactIndex) {
const res = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}/artifacts/${artifactIndex}/media-token`,
{ method: 'POST' }
)
if (!res.ok) throw new Error(`media-token ${res.status}`)
return res.json()
}
// ── Cookie rules ──────────────────────────────────────────────────────────────
export async function listCookieRules() {
@ -491,6 +506,48 @@ export async function deleteCookieRule(ruleUid) {
}
}
// Returns { promise, abort } so callers can cancel an in-flight upload.
// Aborting rejects the promise with "Upload cancelled" and lets the server's
// partial-upload cleanup handle any bytes already written to temp/uploads/.
export function uploadFile(archiveId, file, onProgress) {
let xhr
const promise = new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', file)
xhr = new XMLHttpRequest()
xhr.open('POST', `/api/archives/${archiveId}/uploads`)
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100))
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)) }
catch { reject(new Error('Invalid server response')) }
} else {
let msg = `Upload failed (${xhr.status})`
try { msg = JSON.parse(xhr.responseText).message || msg } catch {}
reject(new Error(msg))
}
})
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')))
xhr.send(formData)
})
return { promise, abort: () => xhr?.abort() }
}
// Best-effort discard of a staged upload file (DELETE /archives/:id/uploads).
// Errors are swallowed — the server also prunes stale dirs on startup.
export async function deleteUpload(archiveId, locator) {
try {
await fetch(`/api/archives/${archiveId}/uploads`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locator }),
})
} catch {}
}
// ── 401 interceptor ───────────────────────────────────────────────────────────
const _origFetch = window.fetch;
window.fetch = async (...args) => {

View file

@ -1,5 +1,5 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings } from '../api'
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings, uploadFile, deleteUpload } from '../api'
let nextItemId = 1
@ -131,6 +131,32 @@ function makeItem(locator = '') {
}
}
function makeFileItem(filename) {
return {
id: nextItemId++,
kind: 'file',
filename,
uploadProgress: 0,
uploadStatus: 'uploading',
uploadLocator: null,
uploadError: null,
// Fields present for submission-logic compatibility
locator: '',
quality: 'best',
probeState: 'idle',
probeQualities: null,
probeHasAudio: false,
playlistProbeState: 'idle',
playlistInfo: null,
playlistItems: null,
playlistQuality: null,
playlistExpanded: false,
syncEnabled: false,
error: null,
status: 'idle',
}
}
function applyPlaylistQuality(newQ, currentItems) {
if (newQ === 'best') {
return currentItems.map(item => ({ ...item, quality: 'best' }))
@ -174,6 +200,16 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
// stable ref so probe callbacks always see the current archiveId
const archiveIdRef = useRef(archiveId)
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
const fileInputRef = useRef(null)
const [dragOver, setDragOver] = useState(false)
// Mirror of items state kept in a ref so the native 'close' event handler
// can read the latest items without going stale. Updated every render.
const itemsRef = useRef([])
// Set to true in handleArchive before dialog.close() so the 'close' handler
// skips staged-file cleanup (the capture job will clean those up on success).
const isSubmittingRef = useRef(false)
// item.id → abort() function for in-flight XHR uploads
const uploadXhrs = useRef(new Map())
// Stable refs so polling callbacks always use the latest prop values
const onCapturedRef = useRef(onCaptured)
@ -204,8 +240,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
// Persist items to sessionStorage on every change
useEffect(() => {
sessionStorage.setItem('captureItems', JSON.stringify(items))
sessionStorage.setItem('captureItems', JSON.stringify(items.filter(it => it.kind !== 'file')))
}, [items])
// Keep itemsRef in sync so the 'close' handler always sees current items.
useEffect(() => { itemsRef.current = items }, [items])
// Advanced options panel state
const [advancedOpen, setAdvancedOpen] = useState(false)
@ -257,6 +295,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const handler = () => {
probeTimers.current.forEach(id => clearTimeout(id))
probeTimers.current.clear()
// On cancel (Escape / Cancel button) clean up any staged upload files.
// Guard against the Archive path: handleArchive sets isSubmittingRef true
// before dialog.close() so this block is skipped when archiving — the
// capture job handles staged-file cleanup on success instead.
if (!isSubmittingRef.current) {
const aid = archiveIdRef.current
itemsRef.current.forEach(it => {
if (it.kind !== 'file') return
if (it.uploadStatus === 'uploading') {
// Abort the XHR — 'Upload cancelled' rejection is swallowed in
// handleFiles so no spurious error row appears after close.
uploadXhrs.current.get(it.id)?.()
uploadXhrs.current.delete(it.id)
} else if (it.uploadStatus === 'done' && it.uploadLocator) {
deleteUpload(aid, it.uploadLocator).catch(() => {})
}
})
}
isSubmittingRef.current = false
onClose()
}
dialog.addEventListener('close', handler)
@ -394,16 +451,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const msg = e.message || 'Submission failed.'
onToastRef.current(msg, locator)
settleBatch(batchId, 'failed', locator)
// Only delete the staged file when the server definitively rejected the
// request (e.status set by submitCapture on non-2xx HTTP responses).
// A network error (no e.status) means the response may have been lost
// after the server accepted the job — deleting here would race the
// in-flight capture and remove its input file.
if (locator.startsWith('file://') && e.status) {
deleteUpload(aid, locator).catch(() => {})
}
}
}
function handleArchive() {
const toSubmit = items.filter(it => it.locator.trim())
// Guard against the Enter-key shortcut in CaptureRow bypassing the
// disabled button — uploads must be complete before archiving starts.
if (items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')) return
const toSubmit = items.filter(it =>
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
)
if (toSubmit.length === 0) return
if (toSubmit.some(it => hasConflict(it))) return
if (toSubmit.some(it => it.probeState === 'probing' ||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))) return
if (toSubmit.some(it => Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
if (toSubmit.some(it => it.kind !== 'file' && hasConflict(it))) return
if (toSubmit.some(it => it.kind !== 'file' && (
it.probeState === 'probing' ||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))))
return
if (toSubmit.some(it => it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
const batchId = toSubmit.length > 1
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
: null
@ -411,14 +483,22 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
}
// Capture all submission data before any state changes
const submissions = toSubmit.map(it => ({
const submissions = toSubmit.map(it => {
if (it.kind === 'file') {
return { locator: it.uploadLocator, quality: 'best', extraExtensions: {} }
}
return {
locator: it.locator.trim(),
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
extraExtensions: it.playlistItems !== null
? { per_item_quality: Object.fromEntries(it.playlistItems.map(pi => [pi.id, pi.quality])), sync: it.syncEnabled }
: {},
}))
// Reset form and close dialog immediately
}
})
// Reset form and close dialog immediately.
// Set isSubmittingRef before close() — the native 'close' event fires
// synchronously and the handler checks this flag to skip staged-file cleanup.
isSubmittingRef.current = true
setItems([makeItem()])
dialogRef.current?.close()
// Submit each in background
@ -434,6 +514,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
function removeRow(id) {
clearTimeout(probeTimers.current.get(id))
probeTimers.current.delete(id)
const item = itemsRef.current.find(it => it.id === id)
if (item?.kind === 'file') {
if (item.uploadStatus === 'uploading') {
// Abort the in-flight XHR; the server's partial-upload cleanup handles
// any bytes already written to temp/uploads/.
uploadXhrs.current.get(id)?.()
uploadXhrs.current.delete(id)
} else if (item.uploadStatus === 'done' && item.uploadLocator) {
// Discard the fully staged file that was never submitted for capture.
deleteUpload(archiveIdRef.current, item.uploadLocator).catch(() => {})
}
}
setItems(prev => {
const next = prev.filter(it => it.id !== id)
return next.length === 0 ? [makeItem()] : next
@ -532,22 +624,94 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
))
}
function handleFiles(fileList) {
const files = Array.from(fileList)
if (files.length === 0) return
files.forEach(file => {
const newItem = makeFileItem(file.name)
setItems(prev => {
// Replace a sole empty URL row with the file item; otherwise append
if (prev.length === 1 && prev[0].kind !== 'file' && !prev[0].locator.trim()) {
return [newItem]
}
return [...prev, newItem]
})
const aid = archiveIdRef.current
const { promise, abort } = uploadFile(aid, file, progress => {
setItems(prev => prev.map(it =>
it.id === newItem.id ? { ...it, uploadProgress: progress } : it
))
})
uploadXhrs.current.set(newItem.id, abort)
promise
.then(result => {
uploadXhrs.current.delete(newItem.id)
setItems(prev => prev.map(it =>
it.id === newItem.id
? { ...it, uploadStatus: 'done', uploadLocator: result.locator, uploadProgress: 100 }
: it
))
})
.catch(err => {
uploadXhrs.current.delete(newItem.id)
// 'Upload cancelled' means we aborted deliberately (row removed / dialog
// closed); skip the error-state update since the row is already gone.
if (err.message === 'Upload cancelled') return
setItems(prev => prev.map(it =>
it.id === newItem.id
? { ...it, uploadStatus: 'error', uploadError: err.message }
: it
))
})
})
}
function handleDragOver(e) {
e.preventDefault()
e.stopPropagation()
if (e.dataTransfer.types.includes('Files')) setDragOver(true)
}
function handleDragLeave(e) {
// Only clear when leaving the dialog itself, not a child element
if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false)
}
function handleDrop(e) {
e.preventDefault()
e.stopPropagation()
setDragOver(false)
handleFiles(e.dataTransfer.files)
}
function handleFileInput(e) {
handleFiles(e.target.files)
e.target.value = ''
}
const pendingCount = items.filter(it => it.locator.trim()).length
const anyConflict = items.some(it => hasConflict(it))
const anyUploading = items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')
const pendingCount = items.filter(it =>
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
).length
const anyConflict = items.some(it => it.kind !== 'file' && hasConflict(it))
// True if any playlist row has had all its videos deleted — archive would be a no-op.
const anyEmptyPlaylist = items.some(it =>
Array.isArray(it.playlistItems) && it.playlistItems.length === 0
it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0
)
const anyProbing = items.some(it =>
it.kind !== 'file' && (
it.probeState === 'probing' ||
// For playlist sources block unless probe completed successfully:
// idle = debounce not yet fired; probing = in flight; error = no quality data.
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')
)
)
return (
<dialog ref={dialogRef} className="capture-dialog">
<dialog
ref={dialogRef}
className={`capture-dialog${dragOver ? ' capture-dialog--dragover' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="capture-dialog-inner">
<div className="capture-dialog-header">
<h2 className="capture-dialog-title">Capture</h2>
@ -565,6 +729,13 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
<div className="capture-rows">
{items.map((item, idx) => (
item.kind === 'file' ? (
<CaptureFileRow
key={item.id}
item={item}
onRemove={() => removeRow(item.id)}
/>
) : (
<CaptureRow
key={item.id}
item={item}
@ -579,15 +750,38 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
onSyncChange={val => updateSync(item.id, val)}
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
/>
)
))}
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
hidden
onChange={handleFileInput}
aria-hidden="true"
tabIndex={-1}
/>
<div className="capture-add-row-group">
<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
Add URL
</button>
<button type="button" className="capture-add-row capture-add-file" onClick={() => fileInputRef.current?.click()}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="1" width="10" height="14" rx="1.5"/>
<line x1="5.5" y1="5.5" x2="10.5" y2="5.5"/>
<line x1="5.5" y1="8" x2="10.5" y2="8"/>
<line x1="5.5" y1="10.5" x2="8.5" y2="10.5"/>
</svg>
Upload file
</button>
</div>
{/* ── Advanced options ────────────────────────────── */}
<div className="capture-advanced">
@ -701,7 +895,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
type="button"
className="capture-submit"
onClick={handleArchive}
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist}
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist || anyUploading}
>
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button>
@ -887,3 +1081,61 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
</div>
)
}
function CaptureFileRow({ item, onRemove }) {
const uploading = item.uploadStatus === 'uploading'
const done = item.uploadStatus === 'done'
const errored = item.uploadStatus === 'error'
return (
<div className="capture-row">
<div className="capture-row-main">
<span className="capture-file-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
<rect x="3" y="1" width="10" height="14" rx="1.5"/>
<line x1="5.5" y1="5.5" x2="10.5" y2="5.5"/>
<line x1="5.5" y1="8" x2="10.5" y2="8"/>
<line x1="5.5" y1="10.5" x2="8.5" y2="10.5"/>
</svg>
</span>
<span className={`capture-file-name${errored ? ' capture-file-name--error' : ''}`}>
{item.filename}
</span>
{uploading && (
<span className="capture-file-progress-wrap" aria-label={`Uploading ${item.uploadProgress}%`}>
<span className="capture-file-progress-bar">
<span
className="capture-file-progress-fill"
style={{ width: `${item.uploadProgress}%` }}
/>
</span>
<span className="capture-file-progress-pct">{item.uploadProgress}%</span>
</span>
)}
{done && (
<span className="capture-file-badge capture-file-badge--done" aria-label="Upload complete">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 11, height: 11 }}>
<polyline points="3 8.5 6.5 12 13 5"/>
</svg>
</span>
)}
{errored && (
<span className="capture-file-badge capture-file-badge--error">!</span>
)}
<button
type="button"
className="capture-row-action capture-row-remove"
onClick={onRemove}
aria-label="Remove"
disabled={uploading}
>
<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>
{errored && (
<p className="capture-row-error">{item.uploadError || 'Upload failed'}</p>
)}
</div>
)
}

View file

@ -243,7 +243,6 @@ export default function CollectionsView({ archiveId }) {
className="coll-vis-select"
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
onChange={e => handleVisChange(Number(e.target.value))}
disabled={isDefault}
>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>

View file

@ -6,6 +6,10 @@ import TweetPreview from './TweetPreview';
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'mkv', 'avi', 'm4v', 'ogv']);
const AUDIO_EXTS = new Set(['mp3', 'ogg', 'm4a', 'opus', 'wav', 'flac', 'aac']);
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']);
const CONTENT_TYPES = {
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
mkv: 'video/x-matroska', avi: 'video/x-msvideo', m4v: 'video/mp4', ogv: 'video/ogg',
};
export default function PreviewPanel({ archiveId, entry, detail, fullPage, onXArticle }) {
@ -86,7 +90,10 @@ export default function PreviewPanel({ archiveId, entry, detail, fullPage, onXAr
if (VIDEO_EXTS.has(ext)) {
return (
<div className="preview-panel">
<VideoPreview src={primaryMediaUrl} />
<VideoPreview
src={primaryMediaUrl}
contentType={CONTENT_TYPES[ext] || 'video/mp4'}
/>
</div>
);
}

View file

@ -1,37 +1,187 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { issueMediaToken } from '../api';
export default function VideoPreview({ src }) {
// Regex to extract (archiveId, entryUid, artifactIndex) from an artifact path.
const ARTIFACT_URL_RE =
/\/api\/archives\/([^/]+)\/entries\/([^/]+)\/artifacts\/(\d+)/;
// ── AirPlay icon (screen with upward triangle) ─────────────────────────────
function AirPlayIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-2" />
<polygon points="12 15 17 21 7 21 12 15" />
</svg>
);
}
// ─────────────────────────────────────────────────────────────────────────────
export default function VideoPreview({ src, contentType = 'video/mp4' }) {
const videoRef = useRef(null);
// Signed URL that Cast devices and Apple TV can fetch without a session cookie.
const [signedSrc, setSignedSrc] = useState(null);
const [castReady, setCastReady] = useState(false);
const [airplayReady, setAirplayReady] = useState(false);
// ── Pre-fetch signed media token whenever src changes ───────────────────
// The video element uses the signed URL so AirPlay (Apple TV fetches it
// directly) and Cast (we pass it to loadMedia) both work without auth.
useEffect(() => {
if (videoRef.current) {
videoRef.current.load();
}
if (!src) { setSignedSrc(null); return; }
const m = src.match(ARTIFACT_URL_RE);
if (!m) { setSignedSrc(src); return; } // not a recognised artifact URL
let cancelled = false;
setSignedSrc(null); // reset while fetching
issueMediaToken(m[1], m[2], parseInt(m[3], 10))
.then(({ url }) => { if (!cancelled) setSignedSrc(url); })
.catch(() => { if (!cancelled) setSignedSrc(src); }); // fallback on error
return () => { cancelled = true; };
}, [src]);
// Reload video element when the active source changes.
useEffect(() => {
if (videoRef.current) videoRef.current.load();
}, [signedSrc]);
// ── Detect AirPlay (Safari / WebKit only) ───────────────────────────────
useEffect(() => {
setAirplayReady(
typeof HTMLVideoElement !== 'undefined' &&
'webkitShowPlaybackTargetPicker' in HTMLVideoElement.prototype
);
}, []);
// ── Bootstrap Cast SDK (lazy-injected, only when a video is displayed) ──
// The SDK calls window.__onGCastApiAvailable once it finishes loading.
// CSP: script-src includes https://www.gstatic.com (see security_headers).
useEffect(() => {
const init = (isAvailable) => {
if (!isAvailable) return;
try {
/* global cast, chrome */
cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
setCastReady(true);
} catch (_) {
// SDK unavailable (HTTP page, no Cast extension, etc.) — hide button silently.
}
};
if (window.cast?.framework) {
init(true); // SDK already loaded from a previous video view.
} else {
window.__onGCastApiAvailable = init;
if (!document.querySelector('script[src*="cast_sender"]')) {
const s = document.createElement('script');
s.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
document.head.appendChild(s);
}
}
}, []);
// ── Send video to Cast session ───────────────────────────────────────────
// loadMedia is extracted so it can be called both immediately (if a session
// already exists when signedSrc becomes available) and on future session events.
useEffect(() => {
if (!castReady || !signedSrc) return;
const ctx = cast.framework.CastContext.getInstance();
const { CastContextEventType, SessionState } = cast.framework;
const loadMedia = (session) => {
if (!session) return;
const mediaInfo = new chrome.cast.media.MediaInfo(
window.location.origin + signedSrc,
contentType
);
session
.loadMedia(new chrome.cast.media.LoadRequest(mediaInfo))
.catch(() => {});
};
// If a session is already active (e.g. resumed before signedSrc was ready,
// or user switches videos while already casting), load immediately.
loadMedia(ctx.getCurrentSession());
// Also handle future session starts triggered by the Cast button.
const onSessionState = (event) => {
if (
event.sessionState === SessionState.SESSION_STARTED ||
event.sessionState === SessionState.SESSION_RESUMED
) {
loadMedia(ctx.getCurrentSession());
}
};
ctx.addEventListener(CastContextEventType.SESSION_STATE_CHANGED, onSessionState);
return () => ctx.removeEventListener(CastContextEventType.SESSION_STATE_CHANGED, onSessionState);
}, [castReady, signedSrc, contentType]);
// ── Handlers ────────────────────────────────────────────────────────────
const handleAirPlay = () => {
videoRef.current?.webkitShowPlaybackTargetPicker?.();
};
const showOverlay = castReady || airplayReady;
return (
<div
className="preview-video-wrap"
style={{
background: '#111',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
minHeight: '240px',
}}
>
<div className="preview-video-wrap" style={{ position: 'relative' }}>
{src ? (
<>
{signedSrc ? (
<video
ref={videoRef}
controls
autoPlay={false}
playsInline
x-webkit-airplay="allow"
style={{ width: '100%', maxHeight: '100%', display: 'block' }}
>
<source src={src} />
<source src={signedSrc} />
Your browser does not support the video element.
</video>
) : (
// Brief loading state while signed URL is being fetched.
<div className="video-tv-loading" aria-label="Loading video…" />
)}
{showOverlay && signedSrc && (
<div className="video-tv-controls">
{airplayReady && (
<button
className="video-tv-btn"
title="AirPlay to device"
aria-label="AirPlay to device"
onClick={handleAirPlay}
>
<AirPlayIcon />
</button>
)}
{castReady && (
// google-cast-launcher is a web component from the Cast SDK.
// It manages its own connection-state icon automatically.
// eslint-disable-next-line react/no-unknown-property
<google-cast-launcher
className="video-tv-btn"
title="Cast to TV"
/>
)}
</div>
)}
</>
) : (
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
No video available

View file

@ -969,6 +969,94 @@ select {
.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); }
/* Upload file button group (Add URL + Upload file side by side) */
.capture-add-row-group {
display: flex;
gap: 8px;
margin-top: 4px;
margin-bottom: 18px;
}
.capture-add-row-group .capture-add-row {
margin-top: 0;
margin-bottom: 0;
flex: 1;
}
/* Drag-over state on the whole dialog */
.capture-dialog--dragover .capture-dialog-inner {
outline: 2px dashed var(--accent);
outline-offset: -6px;
border-radius: var(--r3);
}
/* File row */
.capture-file-icon {
flex-shrink: 0;
color: var(--muted);
display: flex;
align-items: center;
}
.capture-file-name {
flex: 1;
min-width: 0;
font-size: 13.5px;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.capture-file-name--error { color: var(--accent); }
/* Upload progress bar */
.capture-file-progress-wrap {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
}
.capture-file-progress-bar {
width: 80px;
height: 4px;
border-radius: 2px;
background: var(--paper-2);
overflow: hidden;
border: 1px solid var(--line);
}
.capture-file-progress-fill {
display: block;
height: 100%;
background: var(--ink);
border-radius: 2px;
transition: width 0.1s ease;
}
.capture-file-progress-pct {
font-size: 11px;
color: var(--muted);
min-width: 28px;
text-align: right;
}
/* Done / error badge */
.capture-file-badge {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.capture-file-badge--done {
background: #d8eddf;
color: #235c35;
}
.capture-file-badge--error {
background: #f5ddd8;
color: #8d3f30;
}
/* ── Toast stack ─────────────────────────────────────────────────────────── */
.toast-stack {
position: fixed;
@ -2187,6 +2275,50 @@ select {
display: block;
}
/* ── Cast / AirPlay TV overlay ───────────────────────────────────────── */
.video-tv-controls {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 6px;
z-index: 10;
}
.video-tv-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: none;
border-radius: 6px;
background: rgba(0, 0, 0, 0.5);
color: rgba(255, 255, 255, 0.82);
cursor: pointer;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
transition: background 0.15s, color 0.15s;
}
.video-tv-btn:hover {
background: rgba(0, 0, 0, 0.72);
color: #fff;
}
/* google-cast-launcher inherits color for its SVG icon */
google-cast-launcher.video-tv-btn {
color: rgba(255, 255, 255, 0.82);
}
/* Brief spinner shown while the signed media URL is being fetched */
.video-tv-loading {
width: 100%;
min-height: 200px;
background: #0d0d0b;
}
/* ── Iframe preview ───────────────────────────────────────────────── */
.preview-iframe-wrap {
flex: 1;