A nostalgic streaming platform that recreates the golden age of television with AI-matched period commercials.
RetroWatch lets you watch any YouTube video through an authentic CRT TV simulation, complete with Gemini AI-powered commercials that are matched to the content and inserted at natural break points — just like it's 1985.
Modern streaming stripped out the communal, serendipitous texture of old TV. RetroWatch is built to bring that back: a 3D CRT television rendered in Three.js, YouTube playback embedded inside it, and an AI pipeline (Gemini) that picks era-appropriate ads from your own library and splices them in at the right moments.
The project is a full-stack mono-repo: a React 19 SPA talks to a Spring Boot 4 / Java 21 backend, with PostgreSQL for persistence and Clerk for auth. Async video processing runs on a database-backed job queue inside the application, object storage is S3-compatible (MinIO), and the YouTube Data API v3 provides video metadata for break-point detection. Everything runs from docker-compose.yml on a single VPS — no Google Cloud, no managed services.
Every user action follows a path through the same layers: React SPA → Clerk JWT → Spring Boot → external services → PostgreSQL / object storage. Here is how each major flow works end-to-end.
- User signs in via the React SPA. Clerk handles the OAuth2 / email flow and issues a signed JWT.
- The frontend attaches the JWT as a
Bearertoken on every subsequent request. - Spring Boot's auth filter validates the JWT with Clerk's public keys before the request reaches any controller. Unauthenticated requests are rejected at this boundary.
- Frontend — user selects a video file (MP4, max 100 MB). React uploads it to
POST /api/protected/ads/upload. - AdController — validates the MIME type and file size, then hands off to AdService.
- AdService — streams the file to object storage (the
adsbucket, MinIO or Supabase Storage) and writes anAdUploadrow in PostgreSQL (file URL, storage path, size,analysisStatus = pending). - AdAnalysisService (async) — Spring
@Asyncfires a background job immediately:- Downloads the ad video from storage to a local temp file.
- Sends video bytes to Gemini with a structured JSON schema.
- Gemini returns: categories, tone, era style (1950s–modern), keywords, transcript, brand name, energy level (1–10).
- Results are written to the
AdMetadatatable;AdUpload.analysisStatusflips tocompleted(orfailed). - Temp files are deleted.
- Frontend — the upload call returns as soon as step 3 finishes. Analysis happens in the background; the UI can poll the ad list to see when analysis completes.
- Frontend — user pastes a YouTube URL. React calls
POST /api/protected/video/analyze. - VideoAnalysisController → YouTubeAnalysisService:
- Extracts the video ID and checks the
VideoAnalysistable for a cached result. If a cached entry exists it is returned immediately. - Otherwise, fetches metadata (title, description, duration, category, tags) from the YouTube Data API v3.
- Sends that metadata to Gemini which returns: categories, topics, sentiment, and a list of ad-break suggestions (timestamp + priority + reason + suggested ad categories).
- Extracts the video ID and checks the
- The full analysis is written to the
VideoAnalysistable (keyed by video ID) and returned to the frontend.
- Frontend — user selects a video URL and a set of uploaded ads, then calls
POST /api/protected/match. - AdMatchingController → AdMatchingService:
- Calls YouTubeAnalysisService (returns cached analysis if available).
- Loads
AdUpload+AdMetadatarows for every selected ad. - Scores each ad against the video using a weighted formula:
- Category overlap × 0.40 (Jaccard similarity)
- Tone compatibility × 0.25 (tone vs. sentiment matrix)
- Era style bonus × 0.20 (retro-era ads score higher)
- Energy level match × 0.15
- Ranks ads by score descending.
- Assigns top-scoring ads to high-priority break timestamps, enforcing a 120-second minimum gap between ads and capping at
maxAds(default 3).
- Returns a
MatchResponse: video analysis summary + a sortedAdScheduleItemlist (ad URL, insertion timestamp, duration, score, reason). No database writes happen here; matching is stateless.
- Frontend — user triggers processing with a chosen shader style. React calls
POST /api/protected/process-video. - ProcessVideoController:
- Confirms the video and ads exist in storage.
- Checks that no processing job is already queued for this user.
- Creates a
ProcessingStatusrow (jobId, stage =QUEUED, progress = 0%). - Writes a row to the
queued_jobtable.JobDispatcherpolls that table inside the same process and POSTs the job to the worker endpoint with a shared token (WORKER_AUTH_TOKEN). Claims useFOR UPDATE SKIP LOCKED, attempts are retried with exponential backoff, and a lease makes crashed dispatchers recoverable.
- VideoWorkerController (dispatcher-authenticated worker endpoint):
The worker updates
ProcessingStatusas it moves through stages:Stage Progress DOWNLOADING — fetches video + ads from storage 5% ANALYZING — uploads video to Gemini Files API; Gemini returns scene breaks and ad insertion points 15–25% APPLYING_EFFECTS — FFmpeg applies the chosen shader (VHS / CRT / VHS-CRT / Glitch) 40% INSERTING_ADS — FFmpeg splices ads at Gemini-determined timestamps 55% ADDING_AUDIO_EFFECTS — overlays vintage crackle audio 70% UPLOADING — final MP4 written to processed-videosbucket85% COMPLETED 100% - Saves a
ProcessedVideorow (shader style, output URL, insertion points, video summary). - Deletes all temp files.
- Saves a
- Frontend — polls
ProcessingStatusto drive a progress bar. When status isCOMPLETED, the signed URL for the processed video is available.
- Frontend — fetches a signed URL for the processed video (1-hour expiry) via the library endpoint.
- CRTModelViewer (Three.js / React Three Fiber) renders a 3D CRT television. The processed video — with effects and ads already baked in — plays inside the CRT model.
- After viewing, React posts a watch history entry (
POST /api/protected/library/history) recording the YouTube URL and which ads were shown. - LibraryController writes a
watch_historyrow in PostgreSQL;GET /api/protected/library/historyretrieves the user's viewing history.
- Docker and Docker Compose — runs the whole stack (application, PostgreSQL, MinIO)
- Node.js 20+ and pnpm, Java 21+ — only if you run the frontend or backend outside Docker
- A Clerk application for auth
- Optional: a Gemini API key (ad analysis) and a YouTube Data API v3 key (break-point detection)
cp .env.example .env # fill in the required values
docker compose up -d --buildAccess the app at http://localhost:8080.
Configuration lives in .env; .env.example documents every variable. For a public
server with automatic HTTPS, see deploy/README.md.
The important ones:
# Required
POSTGRES_PASSWORD=<openssl rand -base64 32>
WORKER_AUTH_TOKEN=<openssl rand -hex 32>
S3_SECRET_KEY=<openssl rand -base64 32>
VITE_CLERK_PUBLISHABLE_KEY=pk_test_...
# Optional but needed for AI features
GEMINI_API_KEY=...
YOUTUBE_API_KEY=...
# Point Gemini at your own gateway instead of Google's API
# GEMINI_BASE_URL=http://litellm:4000VITE_CLERK_PUBLISHABLE_KEY is compiled into the frontend bundle, so rebuild the
image (docker compose build app) after changing it.
docker compose up -d --build # start
docker compose logs -f app # follow logs
docker compose down # stop (keeps data)Access the app at http://localhost:8080. docker compose --profile tls up -d adds a
Caddy reverse proxy with automatic Let's Encrypt certificates.
kind create cluster --config kind-config.yaml
tilt upSee k8s/app.yaml for the Secret the deployment expects.
# Frontend
cd frontend
pnpm install
pnpm dev # http://localhost:5173
# Backend (separate terminal)
cd backend
mvn spring-boot:runThe manual path still needs a PostgreSQL instance, an S3-compatible endpoint and the same environment variables as the compose stack.
cd frontend
pnpm dev # dev server
pnpm build # production build
pnpm lint # lintcd backend
mvn test # run tests (uses H2, no external services needed)
mvn package # build JAR
mvn spring-boot:run # run locallygit pull && docker compose up -d --build
./deploy/smoke-test.sh # verify the running stackFull walkthrough, backups and troubleshooting: deploy/README.md.
All routes are under /api/protected/ and require a valid Clerk JWT.
| Endpoint | Method | Description |
|---|---|---|
/api/protected/ads |
GET, POST | List or upload ads |
/api/protected/ads/{id}/analyze |
POST | Trigger Gemini analysis on an ad |
/api/protected/library/history |
GET, POST | Watch history |
/api/protected/video/analyze |
POST | Analyze a YouTube video for break points |
/api/protected/match |
POST | Build an ad schedule for a video |
For full request/response shapes see the controller source in backend/src/main/java/com/richwavelet/backend/api/.
Questions and bug reports go to GitHub Issues.
PRs are welcome. Please open an issue first to discuss significant changes. There are no formal commit sign-off requirements at this time.
Unlicense — public domain. Teddy Malhan.

