Managing a handful of videos is fine. Managing forty is not — especially when you need to move twelve of them to a new folder, tag eight for review, or delete the ones from last quarter's sprint demos. One at a time doesn't scale.
We shipped four features in SendRec v1.49.0 to close this gap: batch operations in the Library, multi-file upload, a recording countdown timer, and clickable chapter markers in the player seek bar.
Batch operations
Select multiple videos in the Library and act on them at once: delete, move to a folder, or set tags.
The backend
Three new endpoints, all following the same pattern — accept an array of video IDs, validate ownership, execute a single SQL statement:
type batchRequest struct {
VideoIDs []string `json:"videoIds"`
}
Batch delete soft-deletes videos and queues file cleanup asynchronously. Each deleted video fires a webhook so integrations stay in sync:
rows, err := h.db.Query(ctx,
`UPDATE videos SET deleted_at = now()
WHERE id = ANY($1) AND user_id = $2 AND deleted_at IS NULL
RETURNING id, file_key, thumbnail_key, webcam_key`,
req.VideoIDs, userID,
)
The RETURNING clause gives us the storage keys for async cleanup without a second query. We cap batch size at 100 to keep queries predictable.
Batch folder and batch tags follow the same shape. Folder assignment is a single UPDATE. Tags require a delete-then-insert since we're replacing the full tag set:
_, err = tx.Exec(ctx,
`DELETE FROM video_tags WHERE video_id = ANY($1)`,
req.VideoIDs,
)
for _, tagID := range req.TagIDs {
_, err = tx.Exec(ctx,
`INSERT INTO video_tags (video_id, tag_id)
SELECT unnest($1::uuid[]), $2`,
req.VideoIDs, tagID,
)
}
Both validate that the target folder or tags belong to the current user before touching any videos.
The frontend
The Library grows a selection mode. Each video card shows a checkbox on hover. Click one and a batch toolbar appears above the grid:
- N selected counter
- Select all / Deselect all buttons
- Move to folder dropdown
- Delete button with count
The selection state is a Set<string> of video IDs. Changing filters or folders clears the selection to avoid acting on videos you can't see.
Multi-file upload
The Upload page now accepts up to 10 files at once — either through the file picker (which has multiple enabled) or by dropping multiple files on the dropzone.
Each file gets its own title field, pre-filled from the filename. You can edit titles and remove individual files before starting the upload.
Quota check before uploading
This is the interesting part. The backend already enforces monthly video limits per upload — if you're on the free tier with 25 videos/month and you've used 23, each POST /api/videos/upload checks the count and rejects when you hit 25.
But with multi-file upload, failing on file 4 of 5 is a bad experience. The first three upload successfully, the fourth fails, and you're left wondering what happened.
So we check upfront:
const limits = await apiFetch<LimitsResponse>("/api/videos/limits");
if (limits && limits.maxVideosPerMonth > 0) {
const remaining = limits.maxVideosPerMonth - limits.videosUsedThisMonth;
if (files.length > remaining) {
setError(
remaining <= 0
? "Monthly video limit reached"
: `You can only upload ${remaining} more video${remaining === 1 ? "" : "s"} this month`
);
return;
}
}
Pro users have maxVideosPerMonth: 0 (unlimited), so they skip this check entirely. If the limits endpoint fails (self-hosted instances without billing), we proceed anyway and let the backend enforce per-upload.
Files upload sequentially. The progress indicator shows "Uploading 2 of 5..." so you know where you are. If one file fails mid-batch, we continue with the rest and show a results page with both successes and failures.
Recording countdown timer
A small quality-of-life improvement. Before v1.49.0, clicking "Start recording" immediately began capturing. No time to switch windows, position your cursor, or take a breath.
Now there's a 3-2-1 countdown overlay after you select your screen or camera. The countdown ticks down with a pulse animation, and you can click it to skip straight to recording.
The implementation adds a "countdown" state to the recording state machine. After getDisplayMedia or getUserMedia succeeds, we enter countdown instead of recording immediately:
setRecordingState("countdown");
setCountdownValue(3);
A useEffect decrements every second. At zero, it calls beginRecording():
useEffect(() => {
if (recordingState !== "countdown") return;
if (countdownValue <= 0) {
beginRecording();
return;
}
const timer = setTimeout(() => setCountdownValue((v) => v - 1), 1000);
return () => clearTimeout(timer);
}, [recordingState, countdownValue]);
One edge case: the user might revoke screen sharing during the countdown. The getDisplayMedia stream's ended event handler catches this and resets back to idle.
Chapter markers in the seek bar
SendRec already generated AI chapter markers — but they only appeared in the transcript panel. Now they're visible in the player seek bar as colored segments.
The watch page renders chapter segments proportionally across the seek bar:
chapters.forEach(function(ch, i) {
var seg = document.createElement('div');
seg.className = 'chapter-segment';
var startPct = (ch.start / duration) * 100;
var endTime = i < chapters.length - 1 ? chapters[i + 1].start : duration;
var widthPct = ((endTime - ch.start) / duration) * 100;
seg.style.left = startPct + '%';
seg.style.width = widthPct + '%';
seg.title = ch.title;
chaptersBar.appendChild(seg);
});
Chapters are ranges, not points — unlike comment markers which are positioned as dots at specific timestamps. Each segment spans from its start time to the next chapter's start (or the end of the video for the last chapter).
Hovering a segment shows the chapter title. The active chapter highlights as the video plays. Clicking a segment seeks to that chapter's start time. The same visualization works in both the full watch page and the lightweight embed player.
Try it
All four features are live at app.sendrec.eu. SendRec is open source (AGPL-3.0) — the batch operations implementation is in batch.go and the multi-file upload in Upload.tsx.
Top comments (0)