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:
parent
6377daadae
commit
1af920eb63
17 changed files with 1498 additions and 142 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -142,6 +142,7 @@ dependencies = [
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -198,6 +199,7 @@ dependencies = [
|
||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ edition = "2024"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
axum = "0.7.9"
|
axum = { version = "0.7.9", features = ["multipart"] }
|
||||||
chrono = "0.4.42"
|
chrono = "0.4.42"
|
||||||
clap = { version = "4.5.48", features = ["derive"] }
|
clap = { version = "4.5.48", features = ["derive"] }
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
|
|
@ -22,7 +22,7 @@ serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.132"
|
serde_json = "1.0.132"
|
||||||
sha3 = "0.10.8"
|
sha3 = "0.10.8"
|
||||||
tempfile = "3.13.0"
|
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"
|
toml = "0.8.19"
|
||||||
tower = "0.5.1"
|
tower = "0.5.1"
|
||||||
tower-http = { version = "0.6.2", features = ["fs", "trace"] }
|
tower-http = { version = "0.6.2", features = ["fs", "trace"] }
|
||||||
|
|
|
||||||
|
|
@ -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 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)?;
|
add_entry_to_collection(conn, default_coll_id, id, vbits)?;
|
||||||
|
|
||||||
Ok(ArchivedEntry {
|
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.
|
/// Returns the id of the '_default_' collection, creating it if absent.
|
||||||
pub fn ensure_default_collection(conn: &Connection) -> Result<i64> {
|
pub fn ensure_default_collection(conn: &Connection) -> Result<i64> {
|
||||||
let now = now_timestamp();
|
let now = now_timestamp();
|
||||||
|
|
@ -2466,7 +2482,8 @@ pub fn get_entry_collection_memberships(
|
||||||
|
|
||||||
/// Renames a collection and/or updates its default_visibility_bits.
|
/// Renames a collection and/or updates its default_visibility_bits.
|
||||||
/// Returns true if updated, false if not found.
|
/// 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(
|
pub fn update_collection(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
collection_uid: &str,
|
collection_uid: &str,
|
||||||
|
|
@ -2475,8 +2492,8 @@ pub fn update_collection(
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let coll = get_collection_by_uid(conn, collection_uid)?;
|
let coll = get_collection_by_uid(conn, collection_uid)?;
|
||||||
let Some(coll) = coll else { return Ok(false) };
|
let Some(coll) = coll else { return Ok(false) };
|
||||||
if coll.slug == "_default_" {
|
if coll.slug == "_default_" && new_name.is_some() {
|
||||||
anyhow::bail!("cannot modify the default collection");
|
anyhow::bail!("cannot rename the default collection");
|
||||||
}
|
}
|
||||||
let name = new_name.unwrap_or(&coll.name);
|
let name = new_name.unwrap_or(&coll.name);
|
||||||
let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits);
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::archive;
|
||||||
use std::{
|
use std::{
|
||||||
env, fs,
|
env, fs,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
|
@ -2930,6 +2948,121 @@ mod tests {
|
||||||
assert_eq!(public_index_entry_count(&conn).unwrap(), 1);
|
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]
|
#[test]
|
||||||
fn hierarchical_tag_assignments_are_discoverable_through_ancestors() {
|
fn hierarchical_tag_assignments_are_discoverable_through_ancestors() {
|
||||||
let conn = conn();
|
let conn = conn();
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ rusqlite.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,64 @@ use std::{net::SocketAddr, path::PathBuf};
|
||||||
|
|
||||||
const DEFAULT_BIND: &str = "127.0.0.1:8080";
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let config_path = std::env::args()
|
let config_path = std::env::args()
|
||||||
|
|
@ -30,15 +88,34 @@ async fn main() -> Result<()> {
|
||||||
for archive in ®istry.archives {
|
for archive in ®istry.archives {
|
||||||
if let Ok(conn) = archivr_core::database::open_or_initialize(&archive.archive_path) {
|
if let Ok(conn) = archivr_core::database::open_or_initialize(&archive.archive_path) {
|
||||||
match archivr_core::database::fail_stalled_capture_jobs(&conn) {
|
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),
|
Ok(n) if n > 0 => eprintln!(
|
||||||
Err(e) => eprintln!("warn: stalled job cleanup failed for '{}': {e:#}", archive.id),
|
"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 ®istry.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_auth_path = auth_db_path.clone();
|
||||||
|
let cleanup_registry = registry.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) {
|
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;
|
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?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
println!("archivr-server listening on http://{addr}");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ use std::{
|
||||||
use archivr_core::{archive, capture, database, downloader};
|
use archivr_core::{archive, capture, database, downloader};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{ConnectInfo, Path, Query, Request, State},
|
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, Request, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
|
|
@ -50,11 +50,21 @@ use rusqlite::OptionalExtension;
|
||||||
const LOGIN_WINDOW: Duration = Duration::from_secs(15 * 60);
|
const LOGIN_WINDOW: Duration = Duration::from_secs(15 * 60);
|
||||||
const LOGIN_MAX_ATTEMPTS: usize = 5;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
registry: Arc<ServerRegistry>,
|
registry: Arc<ServerRegistry>,
|
||||||
pub auth_db_path: Arc<std::path::PathBuf>,
|
pub auth_db_path: Arc<std::path::PathBuf>,
|
||||||
pub login_attempts: Arc<Mutex<HashMap<IpAddr, VecDeque<Instant>>>>,
|
pub login_attempts: Arc<Mutex<HashMap<IpAddr, VecDeque<Instant>>>>,
|
||||||
|
pub media_tokens: Arc<Mutex<HashMap<String, MediaToken>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, Default)]
|
#[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::header::HeaderName::from_static("content-security-policy"),
|
||||||
axum::http::HeaderValue::from_static(
|
axum::http::HeaderValue::from_static(
|
||||||
"default-src 'self'; \
|
"default-src 'self'; \
|
||||||
script-src 'self'; \
|
script-src 'self' https://www.gstatic.com; \
|
||||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
|
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
|
||||||
img-src 'self' data: blob: https:; \
|
img-src 'self' data: blob: https:; \
|
||||||
font-src 'self' https://fonts.gstatic.com; \
|
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",
|
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
||||||
get(serve_artifact),
|
get(serve_artifact),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index/media-token",
|
||||||
|
post(issue_media_token),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/rearchive",
|
"/api/archives/:archive_id/entries/:entry_uid/rearchive",
|
||||||
post(rearchive_handler),
|
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/blobs/:sha256", get(serve_blob))
|
||||||
.route("/api/archives/:archive_id/runs", get(list_runs))
|
.route("/api/archives/:archive_id/runs", get(list_runs))
|
||||||
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
.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(
|
.route(
|
||||||
"/api/archives/:archive_id/captures/probe",
|
"/api/archives/:archive_id/captures/probe",
|
||||||
get(probe_handler),
|
get(probe_handler),
|
||||||
|
|
@ -386,6 +406,7 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
|
||||||
registry: Arc::new(registry),
|
registry: Arc::new(registry),
|
||||||
auth_db_path: Arc::new(auth_db_path),
|
auth_db_path: Arc::new(auth_db_path),
|
||||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
media_tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
app_with_state(state)
|
app_with_state(state)
|
||||||
}
|
}
|
||||||
|
|
@ -421,7 +442,11 @@ async fn list_entry_children(
|
||||||
let mounted = mounted_archive(&state, &archive_id)?;
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
let caller_bits = auth_to_caller_bits(&auth);
|
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(
|
async fn search_entries_handler(
|
||||||
|
|
@ -466,14 +491,41 @@ async fn list_runs(
|
||||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
Ok(Json(archive::list_runs(&conn)?))
|
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(
|
async fn serve_artifact(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>,
|
Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>,
|
||||||
|
Query(params): Query<ArtifactQuery>,
|
||||||
req: Request,
|
req: Request,
|
||||||
) -> Result<Response, ApiError> {
|
) -> 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()?;
|
auth_user.require_auth()?;
|
||||||
|
}
|
||||||
let mounted = mounted_archive(&state, &archive_id)?;
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
let paths = archive::read_archive_paths(&mounted.archive_path)?;
|
let paths = archive::read_archive_paths(&mounted.archive_path)?;
|
||||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
|
@ -491,6 +543,51 @@ async fn serve_artifact(
|
||||||
.into_response())
|
.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(
|
async fn serve_entry_favicon(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -836,6 +933,11 @@ struct UpdateCookieRuleBody {
|
||||||
ordinal: Option<i64>,
|
ordinal: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct DeleteUploadBody {
|
||||||
|
locator: String,
|
||||||
|
}
|
||||||
|
|
||||||
async fn capture_handler(
|
async fn capture_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -866,7 +968,11 @@ async fn capture_handler(
|
||||||
.and_then(|n| n.parse::<u32>().ok())
|
.and_then(|n| n.parse::<u32>().ok())
|
||||||
.is_some()
|
.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!(
|
return Err(ApiError::bad_request(&format!(
|
||||||
"invalid per_item_quality value {bad:?}: must be \"best\", \"audio\", or a height string like \"1080p\""
|
"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.
|
// Spawn background capture.
|
||||||
let locator = body.locator.trim().to_string();
|
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 quality = body.quality.clone();
|
||||||
let archive_path = mounted.archive_path.clone();
|
let archive_path = mounted.archive_path.clone();
|
||||||
let job_uid_bg = job_uid.clone();
|
let job_uid_bg = job_uid.clone();
|
||||||
|
|
@ -976,6 +1100,16 @@ async fn capture_handler(
|
||||||
notes,
|
notes,
|
||||||
)
|
)
|
||||||
.ok();
|
.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) => {
|
Err(e) => {
|
||||||
database::update_capture_job_status(
|
database::update_capture_job_status(
|
||||||
|
|
@ -987,6 +1121,15 @@ async fn capture_handler(
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.ok();
|
.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(
|
async fn get_capture_job_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -1188,8 +1480,11 @@ async fn probe_playlist_handler(
|
||||||
return Err(ApiError::bad_request("locator must not be empty"));
|
return Err(ApiError::bad_request("locator must not be empty"));
|
||||||
}
|
}
|
||||||
// Validate it's a playlist/channel source and expand shorthands.
|
// Validate it's a playlist/channel source and expand shorthands.
|
||||||
let canonical_url = capture::locator_to_playlist_url(&locator)
|
let canonical_url = capture::locator_to_playlist_url(&locator).ok_or_else(|| {
|
||||||
.ok_or_else(|| ApiError::bad_request("locator is not a YouTube playlist, channel, YTM playlist, or Spotify album/playlist"))?;
|
ApiError::bad_request(
|
||||||
|
"locator is not a YouTube playlist, channel, YTM playlist, or Spotify album/playlist",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
// Verify archive exists.
|
// Verify archive exists.
|
||||||
let _ = mounted_archive(&state, &archive_id)?;
|
let _ = mounted_archive(&state, &archive_id)?;
|
||||||
// Resolve cookies.
|
// Resolve cookies.
|
||||||
|
|
@ -3758,6 +4053,7 @@ mod tests {
|
||||||
}),
|
}),
|
||||||
auth_db_path: Arc::new(auth_path),
|
auth_db_path: Arc::new(auth_path),
|
||||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
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" });
|
let bad_creds = serde_json::json!({ "username": "nobody", "password": "wrong" });
|
||||||
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
||||||
|
|
@ -3821,6 +4117,7 @@ mod tests {
|
||||||
}),
|
}),
|
||||||
auth_db_path: Arc::new(auth_path),
|
auth_db_path: Arc::new(auth_path),
|
||||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
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" });
|
let bad_creds = serde_json::json!({ "username": "x", "password": "y" });
|
||||||
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
||||||
|
|
@ -4589,6 +4886,7 @@ mod tests {
|
||||||
}),
|
}),
|
||||||
auth_db_path: Arc::new(auth_path.clone()),
|
auth_db_path: Arc::new(auth_path.clone()),
|
||||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
media_tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
let session_cookie = make_test_session(&auth_path);
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
|
||||||
|
|
@ -4887,4 +5185,237 @@ mod tests {
|
||||||
"extra disk-only file must be deleted"
|
"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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
crates/archivr-server/static/assets/index-5jBL9-i7.css
Normal file
1
crates/archivr-server/static/assets/index-5jBL9-i7.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
47
crates/archivr-server/static/assets/index-D_BVhPjQ.js
Normal file
47
crates/archivr-server/static/assets/index-D_BVhPjQ.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<script type="module" crossorigin src="/assets/index-De3b80Fv.js"></script>
|
<script type="module" crossorigin src="/assets/index-D_BVhPjQ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D8ic-z4p.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-5jBL9-i7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,9 @@ export async function submitCapture(archiveId, locator, quality = null, extensio
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
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" }
|
return res.json(); // { job_uid, status: "pending" }
|
||||||
}
|
}
|
||||||
|
|
@ -448,6 +450,19 @@ export async function rearchiveEntry(archiveId, entryUid) {
|
||||||
return res.json() // { job_uid, status: 'pending' }
|
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 ──────────────────────────────────────────────────────────────
|
// ── Cookie rules ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listCookieRules() {
|
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 ───────────────────────────────────────────────────────────
|
// ── 401 interceptor ───────────────────────────────────────────────────────────
|
||||||
const _origFetch = window.fetch;
|
const _origFetch = window.fetch;
|
||||||
window.fetch = async (...args) => {
|
window.fetch = async (...args) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
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
|
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) {
|
function applyPlaylistQuality(newQ, currentItems) {
|
||||||
if (newQ === 'best') {
|
if (newQ === 'best') {
|
||||||
return currentItems.map(item => ({ ...item, quality: '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
|
// stable ref so probe callbacks always see the current archiveId
|
||||||
const archiveIdRef = useRef(archiveId)
|
const archiveIdRef = useRef(archiveId)
|
||||||
useEffect(() => { archiveIdRef.current = archiveId }, [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
|
// Stable refs so polling callbacks always use the latest prop values
|
||||||
const onCapturedRef = useRef(onCaptured)
|
const onCapturedRef = useRef(onCaptured)
|
||||||
|
|
@ -204,8 +240,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
|
|
||||||
// Persist items to sessionStorage on every change
|
// Persist items to sessionStorage on every change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('captureItems', JSON.stringify(items))
|
sessionStorage.setItem('captureItems', JSON.stringify(items.filter(it => it.kind !== 'file')))
|
||||||
}, [items])
|
}, [items])
|
||||||
|
// Keep itemsRef in sync so the 'close' handler always sees current items.
|
||||||
|
useEffect(() => { itemsRef.current = items }, [items])
|
||||||
|
|
||||||
// Advanced options panel state
|
// Advanced options panel state
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
|
@ -257,6 +295,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
probeTimers.current.forEach(id => clearTimeout(id))
|
probeTimers.current.forEach(id => clearTimeout(id))
|
||||||
probeTimers.current.clear()
|
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()
|
onClose()
|
||||||
}
|
}
|
||||||
dialog.addEventListener('close', handler)
|
dialog.addEventListener('close', handler)
|
||||||
|
|
@ -394,16 +451,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
const msg = e.message || 'Submission failed.'
|
const msg = e.message || 'Submission failed.'
|
||||||
onToastRef.current(msg, locator)
|
onToastRef.current(msg, locator)
|
||||||
settleBatch(batchId, 'failed', 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() {
|
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.length === 0) return
|
||||||
if (toSubmit.some(it => hasConflict(it))) return
|
if (toSubmit.some(it => it.kind !== 'file' && hasConflict(it))) return
|
||||||
if (toSubmit.some(it => it.probeState === 'probing' ||
|
if (toSubmit.some(it => it.kind !== 'file' && (
|
||||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))) return
|
it.probeState === 'probing' ||
|
||||||
if (toSubmit.some(it => Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
(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
|
const batchId = toSubmit.length > 1
|
||||||
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
||||||
: null
|
: 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: [] })
|
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
|
||||||
}
|
}
|
||||||
// Capture all submission data before any state changes
|
// 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(),
|
locator: it.locator.trim(),
|
||||||
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
||||||
extraExtensions: it.playlistItems !== null
|
extraExtensions: it.playlistItems !== null
|
||||||
? { per_item_quality: Object.fromEntries(it.playlistItems.map(pi => [pi.id, pi.quality])), sync: it.syncEnabled }
|
? { 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()])
|
setItems([makeItem()])
|
||||||
dialogRef.current?.close()
|
dialogRef.current?.close()
|
||||||
// Submit each in background
|
// Submit each in background
|
||||||
|
|
@ -434,6 +514,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
function removeRow(id) {
|
function removeRow(id) {
|
||||||
clearTimeout(probeTimers.current.get(id))
|
clearTimeout(probeTimers.current.get(id))
|
||||||
probeTimers.current.delete(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 => {
|
setItems(prev => {
|
||||||
const next = prev.filter(it => it.id !== id)
|
const next = prev.filter(it => it.id !== id)
|
||||||
return next.length === 0 ? [makeItem()] : next
|
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.
|
// True if any playlist row has had all its videos deleted — archive would be a no-op.
|
||||||
const anyEmptyPlaylist = items.some(it =>
|
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 =>
|
const anyProbing = items.some(it =>
|
||||||
|
it.kind !== 'file' && (
|
||||||
it.probeState === 'probing' ||
|
it.probeState === 'probing' ||
|
||||||
// For playlist sources block unless probe completed successfully:
|
// For playlist sources block unless probe completed successfully:
|
||||||
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
||||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')
|
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
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-inner">
|
||||||
<div className="capture-dialog-header">
|
<div className="capture-dialog-header">
|
||||||
<h2 className="capture-dialog-title">Capture</h2>
|
<h2 className="capture-dialog-title">Capture</h2>
|
||||||
|
|
@ -565,6 +729,13 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
|
|
||||||
<div className="capture-rows">
|
<div className="capture-rows">
|
||||||
{items.map((item, idx) => (
|
{items.map((item, idx) => (
|
||||||
|
item.kind === 'file' ? (
|
||||||
|
<CaptureFileRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onRemove={() => removeRow(item.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<CaptureRow
|
<CaptureRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
|
@ -579,15 +750,38 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
onSyncChange={val => updateSync(item.id, val)}
|
onSyncChange={val => updateSync(item.id, val)}
|
||||||
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
|
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</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}>
|
<button type="button" className="capture-add-row" onClick={addRow}>
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<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"/>
|
<line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/>
|
||||||
</svg>
|
</svg>
|
||||||
Add another
|
Add URL
|
||||||
</button>
|
</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 ────────────────────────────── */}
|
{/* ── Advanced options ────────────────────────────── */}
|
||||||
<div className="capture-advanced">
|
<div className="capture-advanced">
|
||||||
|
|
@ -701,7 +895,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
type="button"
|
type="button"
|
||||||
className="capture-submit"
|
className="capture-submit"
|
||||||
onClick={handleArchive}
|
onClick={handleArchive}
|
||||||
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist}
|
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist || anyUploading}
|
||||||
>
|
>
|
||||||
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -887,3 +1081,61 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,6 @@ export default function CollectionsView({ archiveId }) {
|
||||||
className="coll-vis-select"
|
className="coll-vis-select"
|
||||||
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
|
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
|
||||||
onChange={e => handleVisChange(Number(e.target.value))}
|
onChange={e => handleVisChange(Number(e.target.value))}
|
||||||
disabled={isDefault}
|
|
||||||
>
|
>
|
||||||
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import TweetPreview from './TweetPreview';
|
||||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'mkv', 'avi', 'm4v', 'ogv']);
|
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 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 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 }) {
|
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)) {
|
if (VIDEO_EXTS.has(ext)) {
|
||||||
return (
|
return (
|
||||||
<div className="preview-panel">
|
<div className="preview-panel">
|
||||||
<VideoPreview src={primaryMediaUrl} />
|
<VideoPreview
|
||||||
|
src={primaryMediaUrl}
|
||||||
|
contentType={CONTENT_TYPES[ext] || 'video/mp4'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 videoRef = useRef(null);
|
const ARTIFACT_URL_RE =
|
||||||
|
/\/api\/archives\/([^/]+)\/entries\/([^/]+)\/artifacts\/(\d+)/;
|
||||||
|
|
||||||
useEffect(() => {
|
// ── AirPlay icon (screen with upward triangle) ─────────────────────────────
|
||||||
if (videoRef.current) {
|
function AirPlayIcon() {
|
||||||
videoRef.current.load();
|
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 (!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]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div className="preview-video-wrap" style={{ position: 'relative' }}>
|
||||||
className="preview-video-wrap"
|
|
||||||
style={{
|
|
||||||
background: '#111',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
minHeight: '240px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{src ? (
|
{src ? (
|
||||||
|
<>
|
||||||
|
{signedSrc ? (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
controls
|
controls
|
||||||
autoPlay={false}
|
autoPlay={false}
|
||||||
|
playsInline
|
||||||
|
x-webkit-airplay="allow"
|
||||||
style={{ width: '100%', maxHeight: '100%', display: 'block' }}
|
style={{ width: '100%', maxHeight: '100%', display: 'block' }}
|
||||||
>
|
>
|
||||||
<source src={src} />
|
<source src={signedSrc} />
|
||||||
Your browser does not support the video element.
|
Your browser does not support the video element.
|
||||||
</video>
|
</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' }}>
|
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
|
||||||
No video available
|
No video available
|
||||||
|
|
|
||||||
|
|
@ -969,6 +969,94 @@ select {
|
||||||
.capture-add-row svg { width: 13px; height: 13px; flex-shrink: 0; }
|
.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); }
|
.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 ─────────────────────────────────────────────────────────── */
|
||||||
.toast-stack {
|
.toast-stack {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -2187,6 +2275,50 @@ select {
|
||||||
display: block;
|
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 ───────────────────────────────────────────────── */
|
/* ── Iframe preview ───────────────────────────────────────────────── */
|
||||||
.preview-iframe-wrap {
|
.preview-iframe-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue