DEV Community

Alex Neamtu
Alex Neamtu

Posted on • Originally published at sendrec.eu

How We Added Batch Operations and Multi-File Upload to SendRec

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"`
}
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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,
    )
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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)