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

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

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

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

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

* frontend: Cast/AirPlay overlay in VideoPreview

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* frontend: load Cast media immediately if session already exists

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

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

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

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

* server: staged file-upload endpoint

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

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

* frontend: file upload in Capture dialog

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

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

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

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

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

* fix: collection enrollment with default_visibility_bits

Two related fixes from feat-file-uploading:

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

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

* server: fix unbounded staged-upload disk growth

Two review findings:

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

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

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

Server (main.rs + routes.rs):

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

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

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

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

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

Frontend (api.js + CaptureDialog.jsx):

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

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

View file

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

View file

@ -1,5 +1,5 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings } from '../api'
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings, uploadFile, deleteUpload } from '../api'
let nextItemId = 1
@ -131,6 +131,32 @@ function makeItem(locator = '') {
}
}
function makeFileItem(filename) {
return {
id: nextItemId++,
kind: 'file',
filename,
uploadProgress: 0,
uploadStatus: 'uploading',
uploadLocator: null,
uploadError: null,
// Fields present for submission-logic compatibility
locator: '',
quality: 'best',
probeState: 'idle',
probeQualities: null,
probeHasAudio: false,
playlistProbeState: 'idle',
playlistInfo: null,
playlistItems: null,
playlistQuality: null,
playlistExpanded: false,
syncEnabled: false,
error: null,
status: 'idle',
}
}
function applyPlaylistQuality(newQ, currentItems) {
if (newQ === 'best') {
return currentItems.map(item => ({ ...item, quality: 'best' }))
@ -174,6 +200,16 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
// stable ref so probe callbacks always see the current archiveId
const archiveIdRef = useRef(archiveId)
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
const fileInputRef = useRef(null)
const [dragOver, setDragOver] = useState(false)
// Mirror of items state kept in a ref so the native 'close' event handler
// can read the latest items without going stale. Updated every render.
const itemsRef = useRef([])
// Set to true in handleArchive before dialog.close() so the 'close' handler
// skips staged-file cleanup (the capture job will clean those up on success).
const isSubmittingRef = useRef(false)
// item.id → abort() function for in-flight XHR uploads
const uploadXhrs = useRef(new Map())
// Stable refs so polling callbacks always use the latest prop values
const onCapturedRef = useRef(onCaptured)
@ -204,8 +240,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
// Persist items to sessionStorage on every change
useEffect(() => {
sessionStorage.setItem('captureItems', JSON.stringify(items))
sessionStorage.setItem('captureItems', JSON.stringify(items.filter(it => it.kind !== 'file')))
}, [items])
// Keep itemsRef in sync so the 'close' handler always sees current items.
useEffect(() => { itemsRef.current = items }, [items])
// Advanced options panel state
const [advancedOpen, setAdvancedOpen] = useState(false)
@ -257,6 +295,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const handler = () => {
probeTimers.current.forEach(id => clearTimeout(id))
probeTimers.current.clear()
// On cancel (Escape / Cancel button) clean up any staged upload files.
// Guard against the Archive path: handleArchive sets isSubmittingRef true
// before dialog.close() so this block is skipped when archiving — the
// capture job handles staged-file cleanup on success instead.
if (!isSubmittingRef.current) {
const aid = archiveIdRef.current
itemsRef.current.forEach(it => {
if (it.kind !== 'file') return
if (it.uploadStatus === 'uploading') {
// Abort the XHR — 'Upload cancelled' rejection is swallowed in
// handleFiles so no spurious error row appears after close.
uploadXhrs.current.get(it.id)?.()
uploadXhrs.current.delete(it.id)
} else if (it.uploadStatus === 'done' && it.uploadLocator) {
deleteUpload(aid, it.uploadLocator).catch(() => {})
}
})
}
isSubmittingRef.current = false
onClose()
}
dialog.addEventListener('close', handler)
@ -394,16 +451,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const msg = e.message || 'Submission failed.'
onToastRef.current(msg, locator)
settleBatch(batchId, 'failed', locator)
// Only delete the staged file when the server definitively rejected the
// request (e.status set by submitCapture on non-2xx HTTP responses).
// A network error (no e.status) means the response may have been lost
// after the server accepted the job — deleting here would race the
// in-flight capture and remove its input file.
if (locator.startsWith('file://') && e.status) {
deleteUpload(aid, locator).catch(() => {})
}
}
}
function handleArchive() {
const toSubmit = items.filter(it => it.locator.trim())
// Guard against the Enter-key shortcut in CaptureRow bypassing the
// disabled button — uploads must be complete before archiving starts.
if (items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')) return
const toSubmit = items.filter(it =>
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
)
if (toSubmit.length === 0) return
if (toSubmit.some(it => hasConflict(it))) return
if (toSubmit.some(it => it.probeState === 'probing' ||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))) return
if (toSubmit.some(it => Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
if (toSubmit.some(it => it.kind !== 'file' && hasConflict(it))) return
if (toSubmit.some(it => it.kind !== 'file' && (
it.probeState === 'probing' ||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))))
return
if (toSubmit.some(it => it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
const batchId = toSubmit.length > 1
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
: null
@ -411,14 +483,22 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
}
// Capture all submission data before any state changes
const submissions = toSubmit.map(it => ({
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>
)
}

View file

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

View file

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

View file

@ -1,37 +1,187 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { issueMediaToken } from '../api';
export default function VideoPreview({ src }) {
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

View file

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