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",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -198,6 +199,7 @@ dependencies = [
|
|||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ edition = "2024"
|
|||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.100"
|
||||
axum = "0.7.9"
|
||||
axum = { version = "0.7.9", features = ["multipart"] }
|
||||
chrono = "0.4.42"
|
||||
clap = { version = "4.5.48", features = ["derive"] }
|
||||
hex = "0.4.3"
|
||||
|
|
@ -22,7 +22,7 @@ serde = { version = "1.0.228", features = ["derive"] }
|
|||
serde_json = "1.0.132"
|
||||
sha3 = "0.10.8"
|
||||
tempfile = "3.13.0"
|
||||
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net"] }
|
||||
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread", "net", "fs", "io-util"] }
|
||||
toml = "0.8.19"
|
||||
tower = "0.5.1"
|
||||
tower-http = { version = "0.6.2", features = ["fs", "trace"] }
|
||||
|
|
|
|||
|
|
@ -1866,9 +1866,24 @@ pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<Arch
|
|||
)?;
|
||||
}
|
||||
|
||||
// Auto-enroll in the default collection with appropriate visibility_bits.
|
||||
// Auto-enroll in the default collection.
|
||||
// Root entries: use the collection's configured default_visibility_bits so the
|
||||
// archive owner's visibility setting is respected — capture always passes
|
||||
// "private" (visibility_to_bits → 0) as a safe fallback regardless of intent.
|
||||
// Child entries (parent_entry_id IS NOT NULL): keep visibility_to_bits(entry.visibility)
|
||||
// so they inherit the private/unlisted bits set by the parent capture path;
|
||||
// list_child_entries_inherits_parent_visibility documents this contract.
|
||||
let default_coll_id = ensure_default_collection(conn)?;
|
||||
let vbits = visibility_to_bits(&entry.visibility);
|
||||
let vbits: u32 = if entry.parent_entry_id.is_none() {
|
||||
conn.query_row(
|
||||
"SELECT default_visibility_bits FROM collections WHERE id = ?1",
|
||||
[default_coll_id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
visibility_to_bits(&entry.visibility)
|
||||
};
|
||||
add_entry_to_collection(conn, default_coll_id, id, vbits)?;
|
||||
|
||||
Ok(ArchivedEntry {
|
||||
|
|
@ -2311,6 +2326,7 @@ pub fn visibility_to_bits(visibility: &str) -> u32 {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/// Returns the id of the '_default_' collection, creating it if absent.
|
||||
pub fn ensure_default_collection(conn: &Connection) -> Result<i64> {
|
||||
let now = now_timestamp();
|
||||
|
|
@ -2466,7 +2482,8 @@ pub fn get_entry_collection_memberships(
|
|||
|
||||
/// Renames a collection and/or updates its default_visibility_bits.
|
||||
/// Returns true if updated, false if not found.
|
||||
/// Refuses to rename the '_default_' collection.
|
||||
/// Refuses to rename the '_default_' collection but allows changing its
|
||||
/// default_visibility_bits so users can control the visibility of new captures.
|
||||
pub fn update_collection(
|
||||
conn: &Connection,
|
||||
collection_uid: &str,
|
||||
|
|
@ -2475,8 +2492,8 @@ pub fn update_collection(
|
|||
) -> Result<bool> {
|
||||
let coll = get_collection_by_uid(conn, collection_uid)?;
|
||||
let Some(coll) = coll else { return Ok(false) };
|
||||
if coll.slug == "_default_" {
|
||||
anyhow::bail!("cannot modify the default collection");
|
||||
if coll.slug == "_default_" && new_name.is_some() {
|
||||
anyhow::bail!("cannot rename the default collection");
|
||||
}
|
||||
let name = new_name.unwrap_or(&coll.name);
|
||||
let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits);
|
||||
|
|
@ -2636,6 +2653,7 @@ pub fn delete_entry_artifacts(conn: &Connection, entry_id: i64) -> Result<usize>
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::archive;
|
||||
use std::{
|
||||
env, fs,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
|
|
@ -2930,6 +2948,121 @@ mod tests {
|
|||
assert_eq!(public_index_entry_count(&conn).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_collection_allows_visibility_change_but_not_rename() {
|
||||
let conn = conn();
|
||||
let coll_uid = {
|
||||
let id = ensure_default_collection(&conn).unwrap();
|
||||
conn.query_row(
|
||||
"SELECT collection_uid FROM collections WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Changing default_visibility_bits on _default_ must succeed.
|
||||
let updated = update_collection(&conn, &coll_uid, None, Some(3)).unwrap();
|
||||
assert!(updated, "visibility change on _default_ should succeed");
|
||||
let bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT default_visibility_bits FROM collections WHERE collection_uid = ?1",
|
||||
[&coll_uid],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bits, 3, "default_visibility_bits should be updated to 3");
|
||||
|
||||
// Renaming _default_ must still be rejected.
|
||||
let err = update_collection(&conn, &coll_uid, Some("My Archive"), None);
|
||||
assert!(err.is_err(), "renaming _default_ must be rejected");
|
||||
assert!(
|
||||
err.unwrap_err().to_string().contains("cannot rename"),
|
||||
"error must mention rename"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_collection_visibility_governs_enrollment_not_entry_visibility() {
|
||||
// Regression: capture hardcodes entry.visibility = "private", but
|
||||
// collection_entries.visibility_bits must come from the collection's
|
||||
// default_visibility_bits so that non-admin users can see new entries.
|
||||
let conn = conn();
|
||||
|
||||
// Confirm the default collection starts with default_visibility_bits = 2
|
||||
// (USER-only / "unlisted").
|
||||
let default_id = ensure_default_collection(&conn).unwrap();
|
||||
let default_bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT default_visibility_bits FROM collections WHERE id = ?1",
|
||||
[default_id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(default_bits, 2, "default collection should start USER-visible");
|
||||
|
||||
// Create an entry with visibility = "private" (what capture always passes).
|
||||
let entry = create_entry_fixture(&conn, "private", None, None);
|
||||
|
||||
// The archived_entries row must keep the caller's value ("private").
|
||||
let stored_vis: String = conn
|
||||
.query_row(
|
||||
"SELECT visibility FROM archived_entries WHERE id = ?1",
|
||||
[entry.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored_vis, "private");
|
||||
|
||||
// But the collection_entries enrollment must use default_visibility_bits (2),
|
||||
// not visibility_to_bits("private") (0) — otherwise the entry is invisible.
|
||||
let enrolled_bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
|
||||
[entry.id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
enrolled_bits, 2,
|
||||
"collection_entries.visibility_bits must come from the collection default, not entry.visibility"
|
||||
);
|
||||
|
||||
// A USER caller (role_bits = 2) must now see the entry.
|
||||
let visible = archive::list_root_entries(&conn, 2).unwrap();
|
||||
assert_eq!(visible.len(), 1, "USER should see the entry after fix");
|
||||
|
||||
// An explicit public entry must still enroll at bits = 3 when the
|
||||
// collection default is changed to public.
|
||||
conn.execute(
|
||||
"UPDATE collections SET default_visibility_bits = 3 WHERE id = ?1",
|
||||
[default_id],
|
||||
)
|
||||
.unwrap();
|
||||
let public_entry = create_entry_fixture(&conn, "public", None, None);
|
||||
let public_bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
|
||||
[public_entry.id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(public_bits, 3, "collection default=public should produce bits=3");
|
||||
|
||||
// Child entries must NOT use the collection default — they keep
|
||||
// visibility_to_bits(entry.visibility) so parent-child visibility
|
||||
// inheritance is not broken (list_child_entries_inherits_parent_visibility).
|
||||
let child = create_entry_fixture(&conn, "private", Some(entry.id), Some(entry.id));
|
||||
let child_bits: u32 = conn
|
||||
.query_row(
|
||||
"SELECT visibility_bits FROM collection_entries WHERE entry_id = ?1",
|
||||
[child.id],
|
||||
|row| row.get::<_, i64>(0).map(|v| v as u32),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(child_bits, 0, "child entries must use visibility_to_bits, not collection default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchical_tag_assignments_are_discoverable_through_ancestors() {
|
||||
let conn = conn();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ rusqlite.workspace = true
|
|||
parking_lot.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
|
|
|||
|
|
@ -7,6 +7,64 @@ use std::{net::SocketAddr, path::PathBuf};
|
|||
|
||||
const DEFAULT_BIND: &str = "127.0.0.1:8080";
|
||||
|
||||
/// Prune abandoned staged-upload UUID dirs under `uploads_dir` whose content
|
||||
/// is older than `cutoff`.
|
||||
///
|
||||
/// `cleanup_stale_sentinels`: pass `true` at startup (before the server begins
|
||||
/// accepting connections) so crash-leftover `.uploading` markers are removed and
|
||||
/// those dirs are subject to the normal age check. Pass `false` from the
|
||||
/// in-process periodic task so dirs with a live sentinel (active XHR) are
|
||||
/// skipped entirely.
|
||||
///
|
||||
/// Staleness is measured from the newest non-sentinel child file's mtime so a
|
||||
/// just-finished slow upload is not pruned before the user submits it for
|
||||
/// capture. Empty dirs fall back to the directory mtime.
|
||||
fn prune_stale_upload_dirs(
|
||||
uploads_dir: &std::path::Path,
|
||||
cutoff: std::time::SystemTime,
|
||||
cleanup_stale_sentinels: bool,
|
||||
) {
|
||||
let Ok(entries) = std::fs::read_dir(uploads_dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let sentinel = path.join(".uploading");
|
||||
if sentinel.exists() {
|
||||
if cleanup_stale_sentinels {
|
||||
// Server just started — no uploads are in flight, so any
|
||||
// sentinel is a crash remnant. Remove it and fall through
|
||||
// to the age check below.
|
||||
let _ = std::fs::remove_file(&sentinel);
|
||||
} else {
|
||||
// An active XHR is writing to this dir — leave it alone.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Measure staleness from the newest non-sentinel child file so a
|
||||
// completed slow upload isn't pruned while the user is still on the
|
||||
// capture form. Fall back to dir mtime only when the dir is empty.
|
||||
let newest_child = std::fs::read_dir(&path)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name() != std::ffi::OsStr::new(".uploading"))
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.filter_map(|m| m.modified().ok())
|
||||
.max();
|
||||
let reference = newest_child
|
||||
.or_else(|| entry.metadata().and_then(|m| m.modified()).ok())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
if reference < cutoff {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config_path = std::env::args()
|
||||
|
|
@ -30,15 +88,34 @@ async fn main() -> Result<()> {
|
|||
for archive in ®istry.archives {
|
||||
if let Ok(conn) = archivr_core::database::open_or_initialize(&archive.archive_path) {
|
||||
match archivr_core::database::fail_stalled_capture_jobs(&conn) {
|
||||
Ok(n) if n > 0 => eprintln!("info: marked {n} stalled capture job(s) as failed in '{}'", archive.id),
|
||||
Err(e) => eprintln!("warn: stalled job cleanup failed for '{}': {e:#}", archive.id),
|
||||
Ok(n) if n > 0 => eprintln!(
|
||||
"info: marked {n} stalled capture job(s) as failed in '{}'",
|
||||
archive.id
|
||||
),
|
||||
Err(e) => eprintln!(
|
||||
"warn: stalled job cleanup failed for '{}': {e:#}",
|
||||
archive.id
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn session cleanup: runs at startup and every 24h.
|
||||
// Prune staged upload dirs older than 24 h. cleanup_stale_sentinels=true
|
||||
// because no uploads are in flight before the server starts listening.
|
||||
let prune_cutoff = std::time::SystemTime::now()
|
||||
.checked_sub(std::time::Duration::from_secs(24 * 60 * 60))
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
for archive in ®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_registry = registry.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) {
|
||||
|
|
@ -48,6 +125,17 @@ async fn main() -> Result<()> {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
// cleanup_stale_sentinels=false: server is live, respect active uploads.
|
||||
let prune_cutoff = std::time::SystemTime::now()
|
||||
.checked_sub(std::time::Duration::from_secs(24 * 60 * 60))
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
for archive in &cleanup_registry.archives {
|
||||
if let Ok(paths) = archivr_core::archive::read_archive_paths(&archive.archive_path)
|
||||
{
|
||||
let uploads_dir = paths.store_path.join("temp").join("uploads");
|
||||
prune_stale_upload_dirs(&uploads_dir, prune_cutoff, false);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await;
|
||||
}
|
||||
});
|
||||
|
|
@ -63,6 +151,10 @@ async fn main() -> Result<()> {
|
|||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
println!("archivr-server listening on http://{addr}");
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ use std::{
|
|||
use archivr_core::{archive, capture, database, downloader};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{ConnectInfo, Path, Query, Request, State},
|
||||
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
|
|
@ -50,11 +50,21 @@ use rusqlite::OptionalExtension;
|
|||
const LOGIN_WINDOW: Duration = Duration::from_secs(15 * 60);
|
||||
const LOGIN_MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
// Short-lived token granting unauthenticated access to one specific artifact.
|
||||
// Used so Cast / AirPlay devices (which carry no session cookie) can fetch media.
|
||||
pub(crate) struct MediaToken {
|
||||
archive_id: String,
|
||||
entry_uid: String,
|
||||
artifact_index: usize,
|
||||
expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
registry: Arc<ServerRegistry>,
|
||||
pub auth_db_path: Arc<std::path::PathBuf>,
|
||||
pub login_attempts: Arc<Mutex<HashMap<IpAddr, VecDeque<Instant>>>>,
|
||||
pub media_tokens: Arc<Mutex<HashMap<String, MediaToken>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
|
|
@ -133,7 +143,7 @@ async fn security_headers(req: Request, next: Next) -> Response {
|
|||
axum::http::header::HeaderName::from_static("content-security-policy"),
|
||||
axum::http::HeaderValue::from_static(
|
||||
"default-src 'self'; \
|
||||
script-src 'self'; \
|
||||
script-src 'self' https://www.gstatic.com; \
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
|
||||
img-src 'self' data: blob: https:; \
|
||||
font-src 'self' https://fonts.gstatic.com; \
|
||||
|
|
@ -249,6 +259,10 @@ pub fn app_with_state(state: AppState) -> Router {
|
|||
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
||||
get(serve_artifact),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index/media-token",
|
||||
post(issue_media_token),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid/rearchive",
|
||||
post(rearchive_handler),
|
||||
|
|
@ -260,6 +274,12 @@ pub fn app_with_state(state: AppState) -> Router {
|
|||
.route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob))
|
||||
.route("/api/archives/:archive_id/runs", get(list_runs))
|
||||
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
||||
.route(
|
||||
"/api/archives/:archive_id/uploads",
|
||||
post(upload_handler)
|
||||
.delete(delete_upload_handler)
|
||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/captures/probe",
|
||||
get(probe_handler),
|
||||
|
|
@ -386,6 +406,7 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
|
|||
registry: Arc::new(registry),
|
||||
auth_db_path: Arc::new(auth_db_path),
|
||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||
media_tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
app_with_state(state)
|
||||
}
|
||||
|
|
@ -421,7 +442,11 @@ async fn list_entry_children(
|
|||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let caller_bits = auth_to_caller_bits(&auth);
|
||||
Ok(Json(archive::list_child_entries(&conn, &entry_uid, caller_bits)?))
|
||||
Ok(Json(archive::list_child_entries(
|
||||
&conn,
|
||||
&entry_uid,
|
||||
caller_bits,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn search_entries_handler(
|
||||
|
|
@ -466,14 +491,41 @@ async fn list_runs(
|
|||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_runs(&conn)?))
|
||||
}
|
||||
const MEDIA_TOKEN_TTL: Duration = Duration::from_secs(2 * 60 * 60); // 2 h
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
struct ArtifactQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct MediaTokenResponse {
|
||||
url: String,
|
||||
expires_in_secs: u64,
|
||||
}
|
||||
|
||||
async fn serve_artifact(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>,
|
||||
Query(params): Query<ArtifactQuery>,
|
||||
req: Request,
|
||||
) -> Result<Response, ApiError> {
|
||||
auth_user.require_auth()?;
|
||||
// Auth: valid scoped token OR authenticated session (OR both).
|
||||
// A token present but invalid/expired falls back to session auth so that
|
||||
// a logged-in browser player keeps working after a token expires.
|
||||
let token_valid = params.token.as_deref().map_or(false, |tok| {
|
||||
let tokens = state.media_tokens.lock();
|
||||
tokens.get(tok).map_or(false, |t| {
|
||||
t.archive_id == archive_id
|
||||
&& t.entry_uid == entry_uid
|
||||
&& t.artifact_index == artifact_index
|
||||
&& t.expires_at > std::time::Instant::now()
|
||||
})
|
||||
});
|
||||
if !token_valid {
|
||||
auth_user.require_auth()?;
|
||||
}
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let paths = archive::read_archive_paths(&mounted.archive_path)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
|
|
@ -491,6 +543,51 @@ async fn serve_artifact(
|
|||
.into_response())
|
||||
}
|
||||
|
||||
/// POST /api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index/media-token
|
||||
///
|
||||
/// Requires an authenticated session. Returns a short-lived signed URL that
|
||||
/// allows unauthenticated GET of the specified artifact — intended for Cast /
|
||||
/// AirPlay devices that cannot carry the browser's session cookie.
|
||||
async fn issue_media_token(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path((archive_id, entry_uid, artifact_index)): Path<(String, String, usize)>,
|
||||
) -> Result<Json<MediaTokenResponse>, ApiError> {
|
||||
auth_user.require_auth()?;
|
||||
// Verify the artifact actually exists before issuing a token.
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let detail = archive::get_entry_detail(&conn, &entry_uid)?
|
||||
.ok_or(ApiError::not_found("entry not found"))?;
|
||||
if artifact_index >= detail.artifacts.len() {
|
||||
return Err(ApiError::not_found("artifact index out of range"));
|
||||
}
|
||||
let token = auth::generate_token();
|
||||
let now = std::time::Instant::now();
|
||||
{
|
||||
let mut tokens = state.media_tokens.lock();
|
||||
// GC expired tokens on each issuance to keep the map bounded.
|
||||
tokens.retain(|_, t| t.expires_at > now);
|
||||
tokens.insert(
|
||||
token.clone(),
|
||||
MediaToken {
|
||||
archive_id: archive_id.clone(),
|
||||
entry_uid: entry_uid.clone(),
|
||||
artifact_index,
|
||||
expires_at: now + MEDIA_TOKEN_TTL,
|
||||
},
|
||||
);
|
||||
}
|
||||
let url = format!(
|
||||
"/api/archives/{}/entries/{}/artifacts/{}?token={}",
|
||||
archive_id, entry_uid, artifact_index, token
|
||||
);
|
||||
Ok(Json(MediaTokenResponse {
|
||||
url,
|
||||
expires_in_secs: MEDIA_TOKEN_TTL.as_secs(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn serve_entry_favicon(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -836,6 +933,11 @@ struct UpdateCookieRuleBody {
|
|||
ordinal: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct DeleteUploadBody {
|
||||
locator: String,
|
||||
}
|
||||
|
||||
async fn capture_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -866,7 +968,11 @@ async fn capture_handler(
|
|||
.and_then(|n| n.parse::<u32>().ok())
|
||||
.is_some()
|
||||
};
|
||||
if let Some(bad) = body.per_item_quality.values().find(|q| !is_valid_quality(q)) {
|
||||
if let Some(bad) = body
|
||||
.per_item_quality
|
||||
.values()
|
||||
.find(|q| !is_valid_quality(q))
|
||||
{
|
||||
return Err(ApiError::bad_request(&format!(
|
||||
"invalid per_item_quality value {bad:?}: must be \"best\", \"audio\", or a height string like \"1080p\""
|
||||
)));
|
||||
|
|
@ -923,6 +1029,24 @@ async fn capture_handler(
|
|||
|
||||
// Spawn background capture.
|
||||
let locator = body.locator.trim().to_string();
|
||||
// If the locator is a file:// path staged under temp/uploads/, track it for cleanup.
|
||||
// Canonicalize both sides to prevent path-traversal via `..` components in the locator.
|
||||
// Same pattern as artifact serving (line ~631). The staged file must already exist on disk
|
||||
// (it was written by upload_handler), so canonicalize() will resolve symlinks correctly.
|
||||
let staged_upload_path: Option<std::path::PathBuf> = if locator.starts_with("file://") {
|
||||
let file_path = std::path::PathBuf::from(locator.trim_start_matches("file://"));
|
||||
let staging_dir = archive_paths.store_path.join("temp").join("uploads");
|
||||
match (file_path.canonicalize(), staging_dir.canonicalize()) {
|
||||
(Ok(canonical_file), Ok(canonical_staging))
|
||||
if canonical_file.starts_with(&canonical_staging) =>
|
||||
{
|
||||
Some(canonical_file)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let quality = body.quality.clone();
|
||||
let archive_path = mounted.archive_path.clone();
|
||||
let job_uid_bg = job_uid.clone();
|
||||
|
|
@ -976,6 +1100,16 @@ async fn capture_handler(
|
|||
notes,
|
||||
)
|
||||
.ok();
|
||||
// Clean up staged upload file — content is now in the raw store.
|
||||
// `staged` is already the canonicalized path (safe to remove_file directly).
|
||||
// Also attempt to remove the now-empty UUID parent dir; fails silently if
|
||||
// non-empty or already gone.
|
||||
if let Some(staged) = staged_upload_path {
|
||||
let _ = std::fs::remove_file(&staged);
|
||||
if let Some(parent) = staged.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
database::update_capture_job_status(
|
||||
|
|
@ -987,6 +1121,15 @@ async fn capture_handler(
|
|||
None,
|
||||
)
|
||||
.ok();
|
||||
// Failed captures never move the file into the raw store,
|
||||
// so clean up the staged upload here rather than waiting
|
||||
// for the next startup or periodic prune.
|
||||
if let Some(staged) = staged_upload_path {
|
||||
let _ = std::fs::remove_file(&staged);
|
||||
if let Some(parent) = staged.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -997,6 +1140,155 @@ async fn capture_handler(
|
|||
))
|
||||
}
|
||||
|
||||
async fn upload_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
|
||||
auth_user.require_role(ROLE_USER)?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let archive_paths =
|
||||
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
|
||||
|
||||
// Stage under temp/uploads/<uuid>/<safe_name> so Source::Local derives the
|
||||
// entry title from Path::file_name() of the locator rather than the uuid.
|
||||
let staging_base = archive_paths.store_path.join("temp").join("uploads");
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(&e.to_string()))?
|
||||
{
|
||||
if field.name() != Some("file") {
|
||||
continue;
|
||||
}
|
||||
let filename = field.file_name().unwrap_or("upload").to_string();
|
||||
|
||||
// Sanitize: strip path separators and control chars, truncate to 200 chars.
|
||||
let safe_name: String = filename
|
||||
.chars()
|
||||
.filter(|c| !matches!(*c, '/' | '\\' | '\0'))
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string();
|
||||
let safe_name = if safe_name.is_empty() {
|
||||
"upload".to_string()
|
||||
} else {
|
||||
safe_name.chars().take(200).collect()
|
||||
};
|
||||
|
||||
let uuid_dir = staging_base.join(uuid::Uuid::new_v4().simple().to_string());
|
||||
tokio::fs::create_dir_all(&uuid_dir).await.map_err(|e| {
|
||||
ApiError::from(anyhow::anyhow!("failed to create upload staging dir: {e}"))
|
||||
})?;
|
||||
|
||||
// Sentinel: exists while the XHR is streaming. The prune task skips any
|
||||
// uuid_dir that contains this file, so a slow upload is never deleted
|
||||
// mid-transfer regardless of wall-clock age.
|
||||
let sentinel = uuid_dir.join(".uploading");
|
||||
tokio::fs::File::create(&sentinel).await.map_err(|e| {
|
||||
ApiError::from(anyhow::anyhow!("failed to create upload sentinel: {e}"))
|
||||
})?;
|
||||
|
||||
let staged_path = uuid_dir.join(&safe_name);
|
||||
|
||||
// Stream chunks directly to disk — never buffers the full file in RAM.
|
||||
// The body limit (10 GiB) is enforced by the DefaultBodyLimit layer.
|
||||
let stream_result: Result<(), ApiError> = async {
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
let file = tokio::fs::File::create(&staged_path).await.map_err(|e| {
|
||||
ApiError::from(anyhow::anyhow!("failed to create staged file: {e}"))
|
||||
})?;
|
||||
let mut writer = tokio::io::BufWriter::new(file);
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(&e.to_string()))?
|
||||
{
|
||||
writer
|
||||
.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow::anyhow!("failed to write chunk: {e}")))?;
|
||||
}
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow::anyhow!("failed to flush upload: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(e) = stream_result {
|
||||
// remove_dir_all cleans up the partial file and the sentinel together.
|
||||
let _ = tokio::fs::remove_dir_all(&uuid_dir).await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Stream complete — drop the sentinel so the prune task can reclaim the
|
||||
// dir if it is later abandoned without being submitted for capture.
|
||||
let _ = tokio::fs::remove_file(&sentinel).await;
|
||||
|
||||
let size = tokio::fs::metadata(&staged_path)
|
||||
.await
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let locator = format!("file://{}", staged_path.display());
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"locator": locator,
|
||||
"filename": filename,
|
||||
"size": size,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
Err(ApiError::bad_request(
|
||||
"no file field found in multipart upload",
|
||||
))
|
||||
}
|
||||
|
||||
/// `DELETE /api/archives/:archive_id/uploads`
|
||||
///
|
||||
/// Discards a staged upload file that was never submitted for capture —
|
||||
/// called by the frontend when the user removes a file row or cancels the
|
||||
/// dialog. The same canonicalize-then-prefix-check used in `capture_handler`
|
||||
/// prevents path traversal via crafted `file://` locators.
|
||||
async fn delete_upload_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
Json(body): Json<DeleteUploadBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth_user.require_role(ROLE_USER)?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let archive_paths =
|
||||
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
|
||||
|
||||
if !body.locator.starts_with("file://") {
|
||||
return Err(ApiError::bad_request("locator must be a file:// URI"));
|
||||
}
|
||||
let file_path = std::path::PathBuf::from(body.locator.trim_start_matches("file://"));
|
||||
let staging_dir = archive_paths.store_path.join("temp").join("uploads");
|
||||
|
||||
// Canonicalize both sides before the prefix check (path-traversal guard).
|
||||
let (canonical_file, canonical_staging) =
|
||||
match (file_path.canonicalize(), staging_dir.canonicalize()) {
|
||||
(Ok(f), Ok(s)) => (f, s),
|
||||
_ => return Err(ApiError::not_found("staged upload not found")),
|
||||
};
|
||||
if !canonical_file.starts_with(&canonical_staging) {
|
||||
return Err(ApiError::bad_request("locator is not a staged upload"));
|
||||
}
|
||||
|
||||
tokio::fs::remove_file(&canonical_file).await.ok();
|
||||
if let Some(parent) = canonical_file.parent() {
|
||||
tokio::fs::remove_dir(parent).await.ok(); // no-op if non-empty
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn get_capture_job_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -1188,8 +1480,11 @@ async fn probe_playlist_handler(
|
|||
return Err(ApiError::bad_request("locator must not be empty"));
|
||||
}
|
||||
// Validate it's a playlist/channel source and expand shorthands.
|
||||
let canonical_url = capture::locator_to_playlist_url(&locator)
|
||||
.ok_or_else(|| ApiError::bad_request("locator is not a YouTube playlist, channel, YTM playlist, or Spotify album/playlist"))?;
|
||||
let canonical_url = capture::locator_to_playlist_url(&locator).ok_or_else(|| {
|
||||
ApiError::bad_request(
|
||||
"locator is not a YouTube playlist, channel, YTM playlist, or Spotify album/playlist",
|
||||
)
|
||||
})?;
|
||||
// Verify archive exists.
|
||||
let _ = mounted_archive(&state, &archive_id)?;
|
||||
// Resolve cookies.
|
||||
|
|
@ -3758,6 +4053,7 @@ mod tests {
|
|||
}),
|
||||
auth_db_path: Arc::new(auth_path),
|
||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||
media_tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
let bad_creds = serde_json::json!({ "username": "nobody", "password": "wrong" });
|
||||
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
||||
|
|
@ -3821,6 +4117,7 @@ mod tests {
|
|||
}),
|
||||
auth_db_path: Arc::new(auth_path),
|
||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||
media_tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
let bad_creds = serde_json::json!({ "username": "x", "password": "y" });
|
||||
for _ in 0..LOGIN_MAX_ATTEMPTS {
|
||||
|
|
@ -4589,6 +4886,7 @@ mod tests {
|
|||
}),
|
||||
auth_db_path: Arc::new(auth_path.clone()),
|
||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||
media_tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
|
||||
|
|
@ -4887,4 +5185,237 @@ mod tests {
|
|||
"extra disk-only file must be deleted"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Media token tests ────────────────────────────────────────────────────
|
||||
|
||||
// Helper: build a minimal archive + auth setup and return (state, entry_uid, session_cookie).
|
||||
async fn make_media_token_state(
|
||||
dir: &tempfile::TempDir,
|
||||
) -> (AppState, String, std::path::PathBuf, String) {
|
||||
let store_path = dir.path().join("store");
|
||||
let paths =
|
||||
archivr_core::archive::initialize_archive(dir.path(), &store_path, "test", false)
|
||||
.unwrap();
|
||||
// Write artifact file.
|
||||
let artifact_relpath = "raw/m/e/video.mp4";
|
||||
let artifact_dir = store_path.join("raw").join("m").join("e");
|
||||
std::fs::create_dir_all(&artifact_dir).unwrap();
|
||||
std::fs::write(artifact_dir.join("video.mp4"), b"fakevideo").unwrap();
|
||||
// Populate DB.
|
||||
let conn = database::open_or_initialize(&paths.archive_path).unwrap();
|
||||
let user_id = database::ensure_default_user(&conn).unwrap();
|
||||
let sid = database::upsert_source_identity(
|
||||
&conn,
|
||||
"yt",
|
||||
"video",
|
||||
Some("media-token-test"),
|
||||
Some("https://yt.example/v"),
|
||||
"https://yt.example/v",
|
||||
)
|
||||
.unwrap();
|
||||
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
|
||||
let entry = database::create_archived_entry(
|
||||
&conn,
|
||||
&database::NewEntry {
|
||||
source_identity_id: sid,
|
||||
archive_run_id: run.id,
|
||||
parent_entry_id: None,
|
||||
root_entry_id: None,
|
||||
created_by_user_id: user_id,
|
||||
owned_by_user_id: user_id,
|
||||
source_kind: "yt".to_string(),
|
||||
entity_kind: "video".to_string(),
|
||||
title: Some("Test Video".to_string()),
|
||||
visibility: "private".to_string(),
|
||||
representation_kind: "video".to_string(),
|
||||
source_metadata_json: "{}".to_string(),
|
||||
display_metadata_json: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let blob_id = database::upsert_blob(
|
||||
&conn,
|
||||
&database::BlobRecord {
|
||||
sha256: "bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444aaaa1111"
|
||||
.to_string(),
|
||||
byte_size: 9,
|
||||
mime_type: Some("video/mp4".to_string()),
|
||||
extension: Some("mp4".to_string()),
|
||||
raw_relpath: artifact_relpath.to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
database::add_entry_artifact(
|
||||
&conn,
|
||||
&database::NewArtifact {
|
||||
entry_id: entry.id,
|
||||
artifact_role: "primary_media".to_string(),
|
||||
storage_area: "raw".to_string(),
|
||||
relpath: artifact_relpath.to_string(),
|
||||
blob_id: Some(blob_id),
|
||||
logical_path: None,
|
||||
metadata_json: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
let auth_path = dir.path().join("auth.sqlite");
|
||||
{
|
||||
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
|
||||
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
|
||||
}
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let registry = ServerRegistry {
|
||||
archives: vec![MountedArchive {
|
||||
id: "test".to_string(),
|
||||
label: "Test".to_string(),
|
||||
archive_path: paths.archive_path.clone(),
|
||||
}],
|
||||
bind: None,
|
||||
auth_db_path: None,
|
||||
};
|
||||
let state = AppState {
|
||||
registry: Arc::new(registry),
|
||||
auth_db_path: Arc::new(auth_path),
|
||||
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||
media_tokens: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
(state, entry.entry_uid, paths.archive_path, session_cookie)
|
||||
}
|
||||
|
||||
/// Bare artifact URL (no token, no session) must still return 401.
|
||||
#[tokio::test]
|
||||
async fn media_token_bare_artifact_without_auth_returns_401() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (state, entry_uid, _, _) = make_media_token_state(&dir).await;
|
||||
let uri = format!("/api/archives/test/entries/{}/artifacts/0", entry_uid);
|
||||
let response = app_with_state(state)
|
||||
.oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
/// Authenticated POST to media-token, then unauthenticated GET with token → 200.
|
||||
#[tokio::test]
|
||||
async fn media_token_tokenized_artifact_succeeds_unauthenticated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (state, entry_uid, _, session_cookie) = make_media_token_state(&dir).await;
|
||||
// Issue token (authenticated).
|
||||
let token_uri = format!(
|
||||
"/api/archives/test/entries/{}/artifacts/0/media-token",
|
||||
entry_uid
|
||||
);
|
||||
let token_resp = app_with_state(state.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&token_uri)
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(token_resp.status(), StatusCode::OK);
|
||||
let body = body_json(token_resp).await;
|
||||
let signed_url = body["url"].as_str().expect("url field missing");
|
||||
assert!(body["expires_in_secs"].as_u64().unwrap() > 0);
|
||||
// Fetch artifact with signed URL — no session cookie.
|
||||
let artifact_resp = app_with_state(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(signed_url)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(artifact_resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// A bogus token with NO session must return 401 (no valid auth path).
|
||||
#[tokio::test]
|
||||
async fn media_token_invalid_token_returns_401() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (state, entry_uid, _, _) = make_media_token_state(&dir).await;
|
||||
let uri = format!(
|
||||
"/api/archives/test/entries/{}/artifacts/0?token=not-a-real-token",
|
||||
entry_uid
|
||||
);
|
||||
let response = app_with_state(state)
|
||||
.oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
/// A bogus token WITH a valid session must return 200 — the session fallback
|
||||
/// keeps a logged-in browser player working after a token expires.
|
||||
#[tokio::test]
|
||||
async fn media_token_bogus_token_with_session_returns_200() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (state, entry_uid, _, session_cookie) = make_media_token_state(&dir).await;
|
||||
let uri = format!(
|
||||
"/api/archives/test/entries/{}/artifacts/0?token=not-a-real-token",
|
||||
entry_uid
|
||||
);
|
||||
let response = app_with_state(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&uri)
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// A token issued for artifact 0 must not unlock artifact 1.
|
||||
#[tokio::test]
|
||||
async fn media_token_wrong_artifact_index_returns_401() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (state, entry_uid, _, session_cookie) = make_media_token_state(&dir).await;
|
||||
// Issue token for artifact 0.
|
||||
let token_uri = format!(
|
||||
"/api/archives/test/entries/{}/artifacts/0/media-token",
|
||||
entry_uid
|
||||
);
|
||||
let token_resp = app_with_state(state.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&token_uri)
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(token_resp.status(), StatusCode::OK);
|
||||
let body = body_json(token_resp).await;
|
||||
let token = body["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.split("token=")
|
||||
.nth(1)
|
||||
.unwrap();
|
||||
// Try to use it for artifact 1.
|
||||
let wrong_uri = format!(
|
||||
"/api/archives/test/entries/{}/artifacts/1?token={}",
|
||||
entry_uid, token
|
||||
);
|
||||
let response = app_with_state(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&wrong_uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-De3b80Fv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D8ic-z4p.css">
|
||||
<script type="module" crossorigin src="/assets/index-D_BVhPjQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-5jBL9-i7.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -163,7 +163,9 @@ export async function submitCapture(archiveId, locator, quality = null, extensio
|
|||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
const err = new Error(body.error || `HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json(); // { job_uid, status: "pending" }
|
||||
}
|
||||
|
|
@ -448,6 +450,19 @@ export async function rearchiveEntry(archiveId, entryUid) {
|
|||
return res.json() // { job_uid, status: 'pending' }
|
||||
}
|
||||
|
||||
// ── Media token ────────────────────────────────────────────────────────────────
|
||||
// Issues a short-lived signed URL for one artifact — used so Cast / AirPlay
|
||||
// devices (no session cookie) can fetch the media file directly.
|
||||
// Returns { url: string, expires_in_secs: number }.
|
||||
export async function issueMediaToken(archiveId, entryUid, artifactIndex) {
|
||||
const res = await fetch(
|
||||
`/api/archives/${archiveId}/entries/${entryUid}/artifacts/${artifactIndex}/media-token`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) throw new Error(`media-token ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Cookie rules ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listCookieRules() {
|
||||
|
|
@ -491,6 +506,48 @@ export async function deleteCookieRule(ruleUid) {
|
|||
}
|
||||
}
|
||||
|
||||
// Returns { promise, abort } so callers can cancel an in-flight upload.
|
||||
// Aborting rejects the promise with "Upload cancelled" and lets the server's
|
||||
// partial-upload cleanup handle any bytes already written to temp/uploads/.
|
||||
export function uploadFile(archiveId, file, onProgress) {
|
||||
let xhr
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', `/api/archives/${archiveId}/uploads`)
|
||||
xhr.upload.addEventListener('progress', e => {
|
||||
if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
})
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try { resolve(JSON.parse(xhr.responseText)) }
|
||||
catch { reject(new Error('Invalid server response')) }
|
||||
} else {
|
||||
let msg = `Upload failed (${xhr.status})`
|
||||
try { msg = JSON.parse(xhr.responseText).message || msg } catch {}
|
||||
reject(new Error(msg))
|
||||
}
|
||||
})
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')))
|
||||
xhr.send(formData)
|
||||
})
|
||||
return { promise, abort: () => xhr?.abort() }
|
||||
}
|
||||
|
||||
// Best-effort discard of a staged upload file (DELETE /archives/:id/uploads).
|
||||
// Errors are swallowed — the server also prunes stale dirs on startup.
|
||||
export async function deleteUpload(archiveId, locator) {
|
||||
try {
|
||||
await fetch(`/api/archives/${archiveId}/uploads`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locator }),
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── 401 interceptor ───────────────────────────────────────────────────────────
|
||||
const _origFetch = window.fetch;
|
||||
window.fetch = async (...args) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings } from '../api'
|
||||
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings, uploadFile, deleteUpload } from '../api'
|
||||
|
||||
let nextItemId = 1
|
||||
|
||||
|
|
@ -131,6 +131,32 @@ function makeItem(locator = '') {
|
|||
}
|
||||
}
|
||||
|
||||
function makeFileItem(filename) {
|
||||
return {
|
||||
id: nextItemId++,
|
||||
kind: 'file',
|
||||
filename,
|
||||
uploadProgress: 0,
|
||||
uploadStatus: 'uploading',
|
||||
uploadLocator: null,
|
||||
uploadError: null,
|
||||
// Fields present for submission-logic compatibility
|
||||
locator: '',
|
||||
quality: 'best',
|
||||
probeState: 'idle',
|
||||
probeQualities: null,
|
||||
probeHasAudio: false,
|
||||
playlistProbeState: 'idle',
|
||||
playlistInfo: null,
|
||||
playlistItems: null,
|
||||
playlistQuality: null,
|
||||
playlistExpanded: false,
|
||||
syncEnabled: false,
|
||||
error: null,
|
||||
status: 'idle',
|
||||
}
|
||||
}
|
||||
|
||||
function applyPlaylistQuality(newQ, currentItems) {
|
||||
if (newQ === 'best') {
|
||||
return currentItems.map(item => ({ ...item, quality: 'best' }))
|
||||
|
|
@ -174,6 +200,16 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
// stable ref so probe callbacks always see the current archiveId
|
||||
const archiveIdRef = useRef(archiveId)
|
||||
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
|
||||
const fileInputRef = useRef(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
// Mirror of items state kept in a ref so the native 'close' event handler
|
||||
// can read the latest items without going stale. Updated every render.
|
||||
const itemsRef = useRef([])
|
||||
// Set to true in handleArchive before dialog.close() so the 'close' handler
|
||||
// skips staged-file cleanup (the capture job will clean those up on success).
|
||||
const isSubmittingRef = useRef(false)
|
||||
// item.id → abort() function for in-flight XHR uploads
|
||||
const uploadXhrs = useRef(new Map())
|
||||
|
||||
// Stable refs so polling callbacks always use the latest prop values
|
||||
const onCapturedRef = useRef(onCaptured)
|
||||
|
|
@ -204,8 +240,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
|
||||
// Persist items to sessionStorage on every change
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureItems', JSON.stringify(items))
|
||||
sessionStorage.setItem('captureItems', JSON.stringify(items.filter(it => it.kind !== 'file')))
|
||||
}, [items])
|
||||
// Keep itemsRef in sync so the 'close' handler always sees current items.
|
||||
useEffect(() => { itemsRef.current = items }, [items])
|
||||
|
||||
// Advanced options panel state
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
|
@ -257,6 +295,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
const handler = () => {
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
probeTimers.current.clear()
|
||||
// On cancel (Escape / Cancel button) clean up any staged upload files.
|
||||
// Guard against the Archive path: handleArchive sets isSubmittingRef true
|
||||
// before dialog.close() so this block is skipped when archiving — the
|
||||
// capture job handles staged-file cleanup on success instead.
|
||||
if (!isSubmittingRef.current) {
|
||||
const aid = archiveIdRef.current
|
||||
itemsRef.current.forEach(it => {
|
||||
if (it.kind !== 'file') return
|
||||
if (it.uploadStatus === 'uploading') {
|
||||
// Abort the XHR — 'Upload cancelled' rejection is swallowed in
|
||||
// handleFiles so no spurious error row appears after close.
|
||||
uploadXhrs.current.get(it.id)?.()
|
||||
uploadXhrs.current.delete(it.id)
|
||||
} else if (it.uploadStatus === 'done' && it.uploadLocator) {
|
||||
deleteUpload(aid, it.uploadLocator).catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
isSubmittingRef.current = false
|
||||
onClose()
|
||||
}
|
||||
dialog.addEventListener('close', handler)
|
||||
|
|
@ -394,16 +451,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
const msg = e.message || 'Submission failed.'
|
||||
onToastRef.current(msg, locator)
|
||||
settleBatch(batchId, 'failed', locator)
|
||||
// Only delete the staged file when the server definitively rejected the
|
||||
// request (e.status set by submitCapture on non-2xx HTTP responses).
|
||||
// A network error (no e.status) means the response may have been lost
|
||||
// after the server accepted the job — deleting here would race the
|
||||
// in-flight capture and remove its input file.
|
||||
if (locator.startsWith('file://') && e.status) {
|
||||
deleteUpload(aid, locator).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleArchive() {
|
||||
const toSubmit = items.filter(it => it.locator.trim())
|
||||
// Guard against the Enter-key shortcut in CaptureRow bypassing the
|
||||
// disabled button — uploads must be complete before archiving starts.
|
||||
if (items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')) return
|
||||
const toSubmit = items.filter(it =>
|
||||
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
|
||||
)
|
||||
if (toSubmit.length === 0) return
|
||||
if (toSubmit.some(it => hasConflict(it))) return
|
||||
if (toSubmit.some(it => it.probeState === 'probing' ||
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))) return
|
||||
if (toSubmit.some(it => Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
||||
if (toSubmit.some(it => it.kind !== 'file' && hasConflict(it))) return
|
||||
if (toSubmit.some(it => it.kind !== 'file' && (
|
||||
it.probeState === 'probing' ||
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))))
|
||||
return
|
||||
if (toSubmit.some(it => it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
||||
const batchId = toSubmit.length > 1
|
||||
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
||||
: null
|
||||
|
|
@ -411,14 +483,22 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
|
||||
}
|
||||
// Capture all submission data before any state changes
|
||||
const submissions = toSubmit.map(it => ({
|
||||
locator: it.locator.trim(),
|
||||
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
||||
extraExtensions: it.playlistItems !== null
|
||||
? { per_item_quality: Object.fromEntries(it.playlistItems.map(pi => [pi.id, pi.quality])), sync: it.syncEnabled }
|
||||
: {},
|
||||
}))
|
||||
// Reset form and close dialog immediately
|
||||
const submissions = toSubmit.map(it => {
|
||||
if (it.kind === 'file') {
|
||||
return { locator: it.uploadLocator, quality: 'best', extraExtensions: {} }
|
||||
}
|
||||
return {
|
||||
locator: it.locator.trim(),
|
||||
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
||||
extraExtensions: it.playlistItems !== null
|
||||
? { per_item_quality: Object.fromEntries(it.playlistItems.map(pi => [pi.id, pi.quality])), sync: it.syncEnabled }
|
||||
: {},
|
||||
}
|
||||
})
|
||||
// Reset form and close dialog immediately.
|
||||
// Set isSubmittingRef before close() — the native 'close' event fires
|
||||
// synchronously and the handler checks this flag to skip staged-file cleanup.
|
||||
isSubmittingRef.current = true
|
||||
setItems([makeItem()])
|
||||
dialogRef.current?.close()
|
||||
// Submit each in background
|
||||
|
|
@ -434,6 +514,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
function removeRow(id) {
|
||||
clearTimeout(probeTimers.current.get(id))
|
||||
probeTimers.current.delete(id)
|
||||
const item = itemsRef.current.find(it => it.id === id)
|
||||
if (item?.kind === 'file') {
|
||||
if (item.uploadStatus === 'uploading') {
|
||||
// Abort the in-flight XHR; the server's partial-upload cleanup handles
|
||||
// any bytes already written to temp/uploads/.
|
||||
uploadXhrs.current.get(id)?.()
|
||||
uploadXhrs.current.delete(id)
|
||||
} else if (item.uploadStatus === 'done' && item.uploadLocator) {
|
||||
// Discard the fully staged file that was never submitted for capture.
|
||||
deleteUpload(archiveIdRef.current, item.uploadLocator).catch(() => {})
|
||||
}
|
||||
}
|
||||
setItems(prev => {
|
||||
const next = prev.filter(it => it.id !== id)
|
||||
return next.length === 0 ? [makeItem()] : next
|
||||
|
|
@ -532,22 +624,94 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
))
|
||||
}
|
||||
|
||||
function handleFiles(fileList) {
|
||||
const files = Array.from(fileList)
|
||||
if (files.length === 0) return
|
||||
files.forEach(file => {
|
||||
const newItem = makeFileItem(file.name)
|
||||
setItems(prev => {
|
||||
// Replace a sole empty URL row with the file item; otherwise append
|
||||
if (prev.length === 1 && prev[0].kind !== 'file' && !prev[0].locator.trim()) {
|
||||
return [newItem]
|
||||
}
|
||||
return [...prev, newItem]
|
||||
})
|
||||
const aid = archiveIdRef.current
|
||||
const { promise, abort } = uploadFile(aid, file, progress => {
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === newItem.id ? { ...it, uploadProgress: progress } : it
|
||||
))
|
||||
})
|
||||
uploadXhrs.current.set(newItem.id, abort)
|
||||
promise
|
||||
.then(result => {
|
||||
uploadXhrs.current.delete(newItem.id)
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === newItem.id
|
||||
? { ...it, uploadStatus: 'done', uploadLocator: result.locator, uploadProgress: 100 }
|
||||
: it
|
||||
))
|
||||
})
|
||||
.catch(err => {
|
||||
uploadXhrs.current.delete(newItem.id)
|
||||
// 'Upload cancelled' means we aborted deliberately (row removed / dialog
|
||||
// closed); skip the error-state update since the row is already gone.
|
||||
if (err.message === 'Upload cancelled') return
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === newItem.id
|
||||
? { ...it, uploadStatus: 'error', uploadError: err.message }
|
||||
: it
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.dataTransfer.types.includes('Files')) setDragOver(true)
|
||||
}
|
||||
function handleDragLeave(e) {
|
||||
// Only clear when leaving the dialog itself, not a child element
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false)
|
||||
}
|
||||
function handleDrop(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOver(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
function handleFileInput(e) {
|
||||
handleFiles(e.target.files)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const pendingCount = items.filter(it => it.locator.trim()).length
|
||||
const anyConflict = items.some(it => hasConflict(it))
|
||||
|
||||
const anyUploading = items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')
|
||||
const pendingCount = items.filter(it =>
|
||||
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
|
||||
).length
|
||||
const anyConflict = items.some(it => it.kind !== 'file' && hasConflict(it))
|
||||
// True if any playlist row has had all its videos deleted — archive would be a no-op.
|
||||
const anyEmptyPlaylist = items.some(it =>
|
||||
Array.isArray(it.playlistItems) && it.playlistItems.length === 0
|
||||
it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0
|
||||
)
|
||||
const anyProbing = items.some(it =>
|
||||
it.probeState === 'probing' ||
|
||||
// For playlist sources block unless probe completed successfully:
|
||||
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')
|
||||
it.kind !== 'file' && (
|
||||
it.probeState === 'probing' ||
|
||||
// For playlist sources block unless probe completed successfully:
|
||||
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="capture-dialog">
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={`capture-dialog${dragOver ? ' capture-dialog--dragover' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="capture-dialog-inner">
|
||||
<div className="capture-dialog-header">
|
||||
<h2 className="capture-dialog-title">Capture</h2>
|
||||
|
|
@ -565,29 +729,59 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
|
||||
<div className="capture-rows">
|
||||
{items.map((item, idx) => (
|
||||
<CaptureRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
autoFocus={idx === items.length - 1}
|
||||
onLocatorChange={val => updateLocator(item.id, val)}
|
||||
onQualityChange={val => updateQuality(item.id, val)}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
onSubmit={handleArchive}
|
||||
onPlaylistQualityChange={q => updatePlaylistQuality(item.id, q)}
|
||||
onPlaylistItemQualityChange={(vid, q) => updatePlaylistItemQuality(item.id, vid, q)}
|
||||
onPlaylistToggle={() => togglePlaylistExpanded(item.id)}
|
||||
onSyncChange={val => updateSync(item.id, val)}
|
||||
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
|
||||
/>
|
||||
item.kind === 'file' ? (
|
||||
<CaptureFileRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
/>
|
||||
) : (
|
||||
<CaptureRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
autoFocus={idx === items.length - 1}
|
||||
onLocatorChange={val => updateLocator(item.id, val)}
|
||||
onQualityChange={val => updateQuality(item.id, val)}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
onSubmit={handleArchive}
|
||||
onPlaylistQualityChange={q => updatePlaylistQuality(item.id, q)}
|
||||
onPlaylistItemQualityChange={(vid, q) => updatePlaylistItemQuality(item.id, vid, q)}
|
||||
onPlaylistToggle={() => togglePlaylistExpanded(item.id)}
|
||||
onSyncChange={val => updateSync(item.id, val)}
|
||||
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="capture-add-row" onClick={addRow}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/>
|
||||
</svg>
|
||||
Add another
|
||||
</button>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleFileInput}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
<div className="capture-add-row-group">
|
||||
<button type="button" className="capture-add-row" onClick={addRow}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/>
|
||||
</svg>
|
||||
Add URL
|
||||
</button>
|
||||
<button type="button" className="capture-add-row capture-add-file" onClick={() => fileInputRef.current?.click()}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="1" width="10" height="14" rx="1.5"/>
|
||||
<line x1="5.5" y1="5.5" x2="10.5" y2="5.5"/>
|
||||
<line x1="5.5" y1="8" x2="10.5" y2="8"/>
|
||||
<line x1="5.5" y1="10.5" x2="8.5" y2="10.5"/>
|
||||
</svg>
|
||||
Upload file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Advanced options ────────────────────────────── */}
|
||||
<div className="capture-advanced">
|
||||
|
|
@ -701,7 +895,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
type="button"
|
||||
className="capture-submit"
|
||||
onClick={handleArchive}
|
||||
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist}
|
||||
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist || anyUploading}
|
||||
>
|
||||
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
||||
</button>
|
||||
|
|
@ -887,3 +1081,61 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
</div>
|
||||
)
|
||||
}
|
||||
function CaptureFileRow({ item, onRemove }) {
|
||||
const uploading = item.uploadStatus === 'uploading'
|
||||
const done = item.uploadStatus === 'done'
|
||||
const errored = item.uploadStatus === 'error'
|
||||
|
||||
return (
|
||||
<div className="capture-row">
|
||||
<div className="capture-row-main">
|
||||
<span className="capture-file-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
|
||||
<rect x="3" y="1" width="10" height="14" rx="1.5"/>
|
||||
<line x1="5.5" y1="5.5" x2="10.5" y2="5.5"/>
|
||||
<line x1="5.5" y1="8" x2="10.5" y2="8"/>
|
||||
<line x1="5.5" y1="10.5" x2="8.5" y2="10.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className={`capture-file-name${errored ? ' capture-file-name--error' : ''}`}>
|
||||
{item.filename}
|
||||
</span>
|
||||
{uploading && (
|
||||
<span className="capture-file-progress-wrap" aria-label={`Uploading ${item.uploadProgress}%`}>
|
||||
<span className="capture-file-progress-bar">
|
||||
<span
|
||||
className="capture-file-progress-fill"
|
||||
style={{ width: `${item.uploadProgress}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="capture-file-progress-pct">{item.uploadProgress}%</span>
|
||||
</span>
|
||||
)}
|
||||
{done && (
|
||||
<span className="capture-file-badge capture-file-badge--done" aria-label="Upload complete">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 11, height: 11 }}>
|
||||
<polyline points="3 8.5 6.5 12 13 5"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{errored && (
|
||||
<span className="capture-file-badge capture-file-badge--error">!</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="capture-row-action capture-row-remove"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove"
|
||||
disabled={uploading}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{errored && (
|
||||
<p className="capture-row-error">{item.uploadError || 'Upload failed'}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,7 +243,6 @@ export default function CollectionsView({ archiveId }) {
|
|||
className="coll-vis-select"
|
||||
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
|
||||
onChange={e => handleVisChange(Number(e.target.value))}
|
||||
disabled={isDefault}
|
||||
>
|
||||
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import TweetPreview from './TweetPreview';
|
|||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'mkv', 'avi', 'm4v', 'ogv']);
|
||||
const AUDIO_EXTS = new Set(['mp3', 'ogg', 'm4a', 'opus', 'wav', 'flac', 'aac']);
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp']);
|
||||
const CONTENT_TYPES = {
|
||||
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
||||
mkv: 'video/x-matroska', avi: 'video/x-msvideo', m4v: 'video/mp4', ogv: 'video/ogg',
|
||||
};
|
||||
|
||||
export default function PreviewPanel({ archiveId, entry, detail, fullPage, onXArticle }) {
|
||||
|
||||
|
|
@ -86,7 +90,10 @@ export default function PreviewPanel({ archiveId, entry, detail, fullPage, onXAr
|
|||
if (VIDEO_EXTS.has(ext)) {
|
||||
return (
|
||||
<div className="preview-panel">
|
||||
<VideoPreview src={primaryMediaUrl} />
|
||||
<VideoPreview
|
||||
src={primaryMediaUrl}
|
||||
contentType={CONTENT_TYPES[ext] || 'video/mp4'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,187 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { issueMediaToken } from '../api';
|
||||
|
||||
export default function VideoPreview({ src }) {
|
||||
const videoRef = useRef(null);
|
||||
// Regex to extract (archiveId, entryUid, artifactIndex) from an artifact path.
|
||||
const ARTIFACT_URL_RE =
|
||||
/\/api\/archives\/([^/]+)\/entries\/([^/]+)\/artifacts\/(\d+)/;
|
||||
|
||||
// ── AirPlay icon (screen with upward triangle) ─────────────────────────────
|
||||
function AirPlayIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-2" />
|
||||
<polygon points="12 15 17 21 7 21 12 15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function VideoPreview({ src, contentType = 'video/mp4' }) {
|
||||
const videoRef = useRef(null);
|
||||
// Signed URL that Cast devices and Apple TV can fetch without a session cookie.
|
||||
const [signedSrc, setSignedSrc] = useState(null);
|
||||
const [castReady, setCastReady] = useState(false);
|
||||
const [airplayReady, setAirplayReady] = useState(false);
|
||||
|
||||
// ── Pre-fetch signed media token whenever src changes ───────────────────
|
||||
// The video element uses the signed URL so AirPlay (Apple TV fetches it
|
||||
// directly) and Cast (we pass it to loadMedia) both work without auth.
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.load();
|
||||
}
|
||||
if (!src) { setSignedSrc(null); return; }
|
||||
const m = src.match(ARTIFACT_URL_RE);
|
||||
if (!m) { setSignedSrc(src); return; } // not a recognised artifact URL
|
||||
let cancelled = false;
|
||||
setSignedSrc(null); // reset while fetching
|
||||
issueMediaToken(m[1], m[2], parseInt(m[3], 10))
|
||||
.then(({ url }) => { if (!cancelled) setSignedSrc(url); })
|
||||
.catch(() => { if (!cancelled) setSignedSrc(src); }); // fallback on error
|
||||
return () => { cancelled = true; };
|
||||
}, [src]);
|
||||
|
||||
// Reload video element when the active source changes.
|
||||
useEffect(() => {
|
||||
if (videoRef.current) videoRef.current.load();
|
||||
}, [signedSrc]);
|
||||
|
||||
// ── Detect AirPlay (Safari / WebKit only) ───────────────────────────────
|
||||
useEffect(() => {
|
||||
setAirplayReady(
|
||||
typeof HTMLVideoElement !== 'undefined' &&
|
||||
'webkitShowPlaybackTargetPicker' in HTMLVideoElement.prototype
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Bootstrap Cast SDK (lazy-injected, only when a video is displayed) ──
|
||||
// The SDK calls window.__onGCastApiAvailable once it finishes loading.
|
||||
// CSP: script-src includes https://www.gstatic.com (see security_headers).
|
||||
useEffect(() => {
|
||||
const init = (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
try {
|
||||
/* global cast, chrome */
|
||||
cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
setCastReady(true);
|
||||
} catch (_) {
|
||||
// SDK unavailable (HTTP page, no Cast extension, etc.) — hide button silently.
|
||||
}
|
||||
};
|
||||
|
||||
if (window.cast?.framework) {
|
||||
init(true); // SDK already loaded from a previous video view.
|
||||
} else {
|
||||
window.__onGCastApiAvailable = init;
|
||||
if (!document.querySelector('script[src*="cast_sender"]')) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Send video to Cast session ───────────────────────────────────────────
|
||||
// loadMedia is extracted so it can be called both immediately (if a session
|
||||
// already exists when signedSrc becomes available) and on future session events.
|
||||
useEffect(() => {
|
||||
if (!castReady || !signedSrc) return;
|
||||
|
||||
const ctx = cast.framework.CastContext.getInstance();
|
||||
const { CastContextEventType, SessionState } = cast.framework;
|
||||
|
||||
const loadMedia = (session) => {
|
||||
if (!session) return;
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
||||
window.location.origin + signedSrc,
|
||||
contentType
|
||||
);
|
||||
session
|
||||
.loadMedia(new chrome.cast.media.LoadRequest(mediaInfo))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// If a session is already active (e.g. resumed before signedSrc was ready,
|
||||
// or user switches videos while already casting), load immediately.
|
||||
loadMedia(ctx.getCurrentSession());
|
||||
|
||||
// Also handle future session starts triggered by the Cast button.
|
||||
const onSessionState = (event) => {
|
||||
if (
|
||||
event.sessionState === SessionState.SESSION_STARTED ||
|
||||
event.sessionState === SessionState.SESSION_RESUMED
|
||||
) {
|
||||
loadMedia(ctx.getCurrentSession());
|
||||
}
|
||||
};
|
||||
|
||||
ctx.addEventListener(CastContextEventType.SESSION_STATE_CHANGED, onSessionState);
|
||||
return () => ctx.removeEventListener(CastContextEventType.SESSION_STATE_CHANGED, onSessionState);
|
||||
}, [castReady, signedSrc, contentType]);
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────
|
||||
const handleAirPlay = () => {
|
||||
videoRef.current?.webkitShowPlaybackTargetPicker?.();
|
||||
};
|
||||
|
||||
const showOverlay = castReady || airplayReady;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="preview-video-wrap"
|
||||
style={{
|
||||
background: '#111',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '240px',
|
||||
}}
|
||||
>
|
||||
<div className="preview-video-wrap" style={{ position: 'relative' }}>
|
||||
{src ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
autoPlay={false}
|
||||
style={{ width: '100%', maxHeight: '100%', display: 'block' }}
|
||||
>
|
||||
<source src={src} />
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
<>
|
||||
{signedSrc ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
autoPlay={false}
|
||||
playsInline
|
||||
x-webkit-airplay="allow"
|
||||
style={{ width: '100%', maxHeight: '100%', display: 'block' }}
|
||||
>
|
||||
<source src={signedSrc} />
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
) : (
|
||||
// Brief loading state while signed URL is being fetched.
|
||||
<div className="video-tv-loading" aria-label="Loading video…" />
|
||||
)}
|
||||
|
||||
{showOverlay && signedSrc && (
|
||||
<div className="video-tv-controls">
|
||||
{airplayReady && (
|
||||
<button
|
||||
className="video-tv-btn"
|
||||
title="AirPlay to device"
|
||||
aria-label="AirPlay to device"
|
||||
onClick={handleAirPlay}
|
||||
>
|
||||
<AirPlayIcon />
|
||||
</button>
|
||||
)}
|
||||
{castReady && (
|
||||
// google-cast-launcher is a web component from the Cast SDK.
|
||||
// It manages its own connection-state icon automatically.
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
<google-cast-launcher
|
||||
className="video-tv-btn"
|
||||
title="Cast to TV"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: 'var(--muted)', fontFamily: 'var(--sans)', fontSize: '0.9rem' }}>
|
||||
No video available
|
||||
|
|
|
|||
|
|
@ -969,6 +969,94 @@ select {
|
|||
.capture-add-row svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
.capture-add-row:hover { border-color: var(--accent-2); color: var(--ink); background: var(--paper-2); }
|
||||
|
||||
/* Upload file button group (Add URL + Upload file side by side) */
|
||||
.capture-add-row-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.capture-add-row-group .capture-add-row {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Drag-over state on the whole dialog */
|
||||
.capture-dialog--dragover .capture-dialog-inner {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -6px;
|
||||
border-radius: var(--r3);
|
||||
}
|
||||
|
||||
/* File row */
|
||||
.capture-file-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.capture-file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.capture-file-name--error { color: var(--accent); }
|
||||
|
||||
/* Upload progress bar */
|
||||
.capture-file-progress-wrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.capture-file-progress-bar {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--paper-2);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.capture-file-progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ink);
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
.capture-file-progress-pct {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Done / error badge */
|
||||
.capture-file-badge {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.capture-file-badge--done {
|
||||
background: #d8eddf;
|
||||
color: #235c35;
|
||||
}
|
||||
.capture-file-badge--error {
|
||||
background: #f5ddd8;
|
||||
color: #8d3f30;
|
||||
}
|
||||
|
||||
/* ── Toast stack ─────────────────────────────────────────────────────────── */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
|
|
@ -2187,6 +2275,50 @@ select {
|
|||
display: block;
|
||||
}
|
||||
|
||||
/* ── Cast / AirPlay TV overlay ───────────────────────────────────────── */
|
||||
.video-tv-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.video-tv-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.video-tv-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* google-cast-launcher inherits color for its SVG icon */
|
||||
google-cast-launcher.video-tv-btn {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
/* Brief spinner shown while the signed media URL is being fetched */
|
||||
.video-tv-loading {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: #0d0d0b;
|
||||
}
|
||||
|
||||
/* ── Iframe preview ───────────────────────────────────────────────── */
|
||||
.preview-iframe-wrap {
|
||||
flex: 1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue