Skip to content

Repository files navigation

RetroWatch

RetroWatch

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.

Table of Contents

Background

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.

Architecture

RetroWatch System Architecture

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.

1. Authentication

  1. User signs in via the React SPA. Clerk handles the OAuth2 / email flow and issues a signed JWT.
  2. The frontend attaches the JWT as a Bearer token on every subsequent request.
  3. 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.

2. Uploading an Ad

  1. Frontend — user selects a video file (MP4, max 100 MB). React uploads it to POST /api/protected/ads/upload.
  2. AdController — validates the MIME type and file size, then hands off to AdService.
  3. AdService — streams the file to object storage (the ads bucket, MinIO or Supabase Storage) and writes an AdUpload row in PostgreSQL (file URL, storage path, size, analysisStatus = pending).
  4. AdAnalysisService (async) — Spring @Async fires 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 AdMetadata table; AdUpload.analysisStatus flips to completed (or failed).
    • Temp files are deleted.
  5. 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.

3. Analyzing a YouTube Video

  1. Frontend — user pastes a YouTube URL. React calls POST /api/protected/video/analyze.
  2. VideoAnalysisController → YouTubeAnalysisService:
    • Extracts the video ID and checks the VideoAnalysis table 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).
  3. The full analysis is written to the VideoAnalysis table (keyed by video ID) and returned to the frontend.

4. Matching Ads to a Video

  1. Frontend — user selects a video URL and a set of uploaded ads, then calls POST /api/protected/match.
  2. AdMatchingController → AdMatchingService:
    • Calls YouTubeAnalysisService (returns cached analysis if available).
    • Loads AdUpload + AdMetadata rows 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).
  3. Returns a MatchResponse: video analysis summary + a sorted AdScheduleItem list (ad URL, insertion timestamp, duration, score, reason). No database writes happen here; matching is stateless.

5. Processing a Video (async pipeline)

  1. Frontend — user triggers processing with a chosen shader style. React calls POST /api/protected/process-video.
  2. ProcessVideoController:
    • Confirms the video and ads exist in storage.
    • Checks that no processing job is already queued for this user.
    • Creates a ProcessingStatus row (jobId, stage = QUEUED, progress = 0%).
    • Writes a row to the queued_job table. JobDispatcher polls that table inside the same process and POSTs the job to the worker endpoint with a shared token (WORKER_AUTH_TOKEN). Claims use FOR UPDATE SKIP LOCKED, attempts are retried with exponential backoff, and a lease makes crashed dispatchers recoverable.
  3. VideoWorkerController (dispatcher-authenticated worker endpoint): The worker updates ProcessingStatus as 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-videos bucket 85%
    COMPLETED 100%
    • Saves a ProcessedVideo row (shader style, output URL, insertion points, video summary).
    • Deletes all temp files.
  4. Frontend — polls ProcessingStatus to drive a progress bar. When status is COMPLETED, the signed URL for the processed video is available.

6. Watching (Playback)

  1. Frontend — fetches a signed URL for the processed video (1-hour expiry) via the library endpoint.
  2. 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.
  3. After viewing, React posts a watch history entry (POST /api/protected/library/history) recording the YouTube URL and which ads were shown.
  4. LibraryController writes a watch_history row in PostgreSQL; GET /api/protected/library/history retrieves the user's viewing history.

Install

Dependencies

  • 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)

Install

cp .env.example .env      # fill in the required values
docker compose up -d --build

Access 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.

Environment setup

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:4000

VITE_CLERK_PUBLISHABLE_KEY is compiled into the frontend bundle, so rebuild the image (docker compose build app) after changing it.

Usage

Docker

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.

Kubernetes (Tilt)

kind create cluster --config kind-config.yaml
tilt up

See k8s/app.yaml for the Secret the deployment expects.

Manual

# Frontend
cd frontend
pnpm install
pnpm dev        # http://localhost:5173

# Backend (separate terminal)
cd backend
mvn spring-boot:run

The manual path still needs a PostgreSQL instance, an S3-compatible endpoint and the same environment variables as the compose stack.

CLI — frontend scripts

cd frontend
pnpm dev        # dev server
pnpm build      # production build
pnpm lint       # lint

CLI — backend scripts

cd backend
mvn test                # run tests (uses H2, no external services needed)
mvn package             # build JAR
mvn spring-boot:run     # run locally

Deploy to a VPS

git pull && docker compose up -d --build
./deploy/smoke-test.sh          # verify the running stack

Full walkthrough, backups and troubleshooting: deploy/README.md.

API

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/.

Maintainers

@teddymalhan

Contributing

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.

License

Unlicense — public domain. Teddy Malhan.

About

Watch Youtube Videos on an old CRT

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Contributors

Languages