Folders group videos by project. Tags cross-label them. But neither solves a common request: "I want to send someone five videos in a specific order and have them play through automatically."
We added playlists to SendRec in v1.57.0. Create a playlist, add videos, drag them into order, share the link. Viewers get a dark-themed watch page with a sidebar, auto-advance, and watched badges.
Two tables
The migration adds two tables:
CREATE TABLE playlists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
share_token TEXT UNIQUE,
is_shared BOOLEAN NOT NULL DEFAULT false,
share_password TEXT,
require_email BOOLEAN NOT NULL DEFAULT false,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE playlist_videos (
playlist_id UUID NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
video_id UUID NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (playlist_id, video_id)
);
The playlists table mirrors the sharing pattern from videos: share_token for public links, share_password for protection, require_email for gating. We reuse the same cookie-based verification logic — hashSharePassword, signWatchCookie, hasValidWatchCookie — so playlists and videos handle authentication identically.
The junction table playlist_videos holds the ordering. A composite primary key prevents duplicates. ON DELETE CASCADE on both sides means deleting a playlist removes all associations, and deleting a video removes it from every playlist.
Playlists in the video list API
Each video in the library now shows which playlists it belongs to. We use the same JSON aggregate subquery pattern as tags:
COALESCE(
(SELECT json_agg(
json_build_object('id', p.id, 'title', p.title)
ORDER BY p.title
)
FROM playlist_videos pv JOIN playlists p ON p.id = pv.playlist_id
WHERE pv.video_id = v.id),
'[]'::json
) AS playlists_json
One query. No N+1. The COALESCE with '[]'::json guarantees a valid array even when a video isn't in any playlist.
The watch page
Shared playlists get their own server-rendered watch page at /watch/playlist/{shareToken}. It's a Go HTML template with embedded CSS and vanilla JavaScript — no React, no build step, no client-side routing.
The layout is a two-panel design: a dark sidebar on the left with the video list, and the player on the right. The sidebar shows thumbnails (loaded from presigned S3 URLs), titles, durations, and position numbers. The currently playing video gets highlighted.
Auto-advance
When a video ends, a countdown overlay appears: "Up next: [title]" with a 5-second progress bar. The viewer can click "Play Now" to skip the countdown or "Cancel" to stop on the current video. The countdown uses setInterval with 50ms ticks for a smooth progress bar animation.
function startCountdown(nextIndex) {
var nextVideo = videos[nextIndex];
nextTitleEl.textContent = nextVideo.title;
overlay.classList.remove('hidden');
var remaining = 5000;
countdownTimer = setInterval(function() {
remaining -= 50;
progressEl.style.width = Math.max(0, (remaining / 5000) * 100) + '%';
if (remaining <= 0) {
cancelCountdown();
switchVideo(nextIndex);
}
}, 50);
}
Watched badges
A checkmark appears next to videos the viewer has watched. "Watched" means the viewer reached 80% of the video duration, tracked via timeupdate events. The watched set is persisted in localStorage keyed by the playlist's share token, so progress survives page refreshes.
CSP compliance
The watch page uses Content Security Policy with nonce-based style-src. That means no inline style attributes — all visibility toggling uses CSS classes (.hidden, .gate-error.visible) and classList.add/remove in JavaScript instead of element.style.display.
Password and email gates
Shared playlists support the same protection as individual videos:
- Password protection — viewers see a password form before any video loads. On correct password, a signed HMAC cookie is set and the page reloads with full access.
- Email gate — viewers enter their email before watching. The email is stored in a signed cookie for subsequent visits.
Both gates reuse the cookie signing and verification functions from the video watch page. The only difference is the form action URL (/api/watch/playlist/{shareToken}/verify vs /api/watch/{shareToken}/verify).
Managing playlists from video detail
Videos show their playlist memberships in the Organization section alongside folders and tags. Toggle buttons let you add or remove a video from any playlist with one click. When you have more than five playlists, a search field appears to filter the list.
The Add Videos modal on the playlist detail page also includes a search input, filtering available videos by title as you type.
What we didn't build
No drag-and-drop reordering in the UI — videos are reordered with up/down arrow buttons that send position updates to the API. No playlist-level analytics (individual video analytics still work). No collaborative playlists across users. No embed support for playlists.
These are reasonable additions, but the core use case — "send someone a curated set of videos that play in order" — works now.
Try it
Playlists are live at app.sendrec.eu in v1.57.0. Free tier users get up to 3 playlists. Self-hosters get the feature automatically on upgrade — migration 000039 runs on startup.
Top comments (0)