Compare commits
1 Commits
913c5c8790
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c703457c |
142
README.md
142
README.md
@@ -1,119 +1,33 @@
|
|||||||
# VoD System
|
# Overview
|
||||||
|
The VoD System is a powerful clip management platform designed to streamline how you handle your video content. Whether you're a content creator, streamer, or educator, VoD System lets you:
|
||||||
|
|
||||||
A self-hosted clip management and live streaming platform. Upload videos, trim and compress clips, save sections of a live stream, and organise everything through a clean web interface — all running locally under Docker.
|
- Upload clips effortlessly and securely.
|
||||||
|
- Edit and trim videos with intuitive controls.
|
||||||
|
- Compress files to specific file sizes.
|
||||||
|
- Organize your clips for quick access and sharing.
|
||||||
|
|
||||||
This project started in secondary school during lockdown as a Python script to squeeze game clips under Discord's 8 MB file size limit. It has since grown into a full-stack application with a Spring Boot backend, a React/Vite/TypeScript frontend, and an nginx-rtmp sidecar for live streaming.
|
This VoD system began as a small project back in my 5th year of secondary school, during lockdown. I wanted a quick way to send game clips to Discord, but the 8MB file size limit made it frustrating to manually tweak bitrates for every clip. To solve that, I wrote a simple Python script to automate the process. What started as a quick fix has since evolved into a full-fledged system — now built with a Spring Boot backend and a modern React/Vite frontend.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|------------|---------------------------------------------------------|
|
|
||||||
| Backend | Java 21, Spring Boot 3.4, Spring Security, JPA, FFmpeg |
|
|
||||||
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4 |
|
|
||||||
| Database | PostgreSQL 15 |
|
|
||||||
| Streaming | nginx-rtmp |
|
|
||||||
| Auth | Google OAuth 2.0 + JWT |
|
|
||||||
| Runtime | Docker Compose |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- JDK 21+
|
||||||
|
- Node.js 16+
|
||||||
|
- FFMPEG
|
||||||
|
- Google Client keys
|
||||||
|
## Steps
|
||||||
|
1. Create your own two `.env` files based off the `example.env` files in the root and in the frontend folder
|
||||||
|
2. Spin up docker container with `docker compose up`
|
||||||
|
3. Run `./mvnw clean package` to build the jar
|
||||||
|
4. Run `./mvnw spring-boot:run` to run the backend section
|
||||||
|
5. Run `cd frontend && npm install && npm run dev` to build and run the frontend
|
||||||
|
6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
|
||||||
|
|
||||||
- Docker and Docker Compose
|
# Future Plans
|
||||||
- A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret)
|
- **Format Handling**
|
||||||
|
- Backend conversion of non-MP4 files via FFMPEG for broader format support
|
||||||
That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required.
|
- **Input Sources**
|
||||||
|
- Support for local file uploads and YouTube imports
|
||||||
---
|
- **Testing**
|
||||||
|
- Unit tests, including coverage for FFMPEG-related functionality
|
||||||
## Getting Started
|
- **API Documentation**
|
||||||
|
- Comprehensive and maintainable backend API docs
|
||||||
### 1. Configure the environment
|
|
||||||
|
|
||||||
Copy the example file and fill in the values:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `.env` and set the following:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
# OAuth credentials from your Google Cloud Console project
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# A long random string used to sign JWTs — treat it like a password
|
|
||||||
JWT_SECRET_KEY=
|
|
||||||
|
|
||||||
# Absolute paths on the host where persistent data will be stored
|
|
||||||
POSTGRES_DATA=/path/to/postgres/data
|
|
||||||
STREAM_DATA=/path/to/stream/data
|
|
||||||
MEDIA_DATA=/path/to/media/data
|
|
||||||
|
|
||||||
# Database credentials — change these from the defaults before deploying
|
|
||||||
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
|
|
||||||
POSTGRES_USER=myuser
|
|
||||||
POSTGRES_PASSWORD=mypassword
|
|
||||||
POSTGRES_DB=vodSystem
|
|
||||||
|
|
||||||
# The URLs the frontend will use to reach itself and the backend
|
|
||||||
FRONTEND_URL=http://localhost:5173
|
|
||||||
BACKEND_URL=http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Start the application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
This builds and starts four containers: `vod_postgres`, `vod_backend`, `vod_frontend`, and `vod_rtmp`.
|
|
||||||
|
|
||||||
Once everything has started, the interfaces are available at:
|
|
||||||
|
|
||||||
- Frontend: [http://localhost:5173](http://localhost:5173)
|
|
||||||
- Backend API: [http://localhost:8080/api/v1](http://localhost:8080/api/v1)
|
|
||||||
- RTMP ingest: `rtmp://localhost:1935/live`
|
|
||||||
|
|
||||||
### 3. Authenticate
|
|
||||||
|
|
||||||
Log in through the frontend using your Google account. The backend exchanges the Google ID token for a short-lived JWT that is used on all subsequent API requests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Mode
|
|
||||||
|
|
||||||
For local backend or frontend development, a separate Compose file is provided that starts only the infrastructure services (PostgreSQL and the RTMP server), leaving the backend and frontend to run directly on the host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the backend and frontend separately in your usual workflow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
A [Bruno](https://www.usebruno.com/) collection is included under `bruno/VoD-System/` with pre-configured requests covering all major endpoints. Import it directly into Bruno and point the `local` environment at `http://localhost:8080/api/v1`.
|
|
||||||
|
|
||||||
The collection is organised into four folders:
|
|
||||||
|
|
||||||
| Folder | Description |
|
|
||||||
|----------|--------------------------------------------------|
|
|
||||||
| Users | Google OAuth login flow, current user |
|
|
||||||
| Media | Upload, compress, clip stream, save section |
|
|
||||||
| Jobs | Poll job status, download completed output |
|
|
||||||
| Streams | Stream history |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Plans
|
|
||||||
|
|
||||||
- **Format handling** — backend conversion of non-MP4 files via FFmpeg for broader input format support
|
|
||||||
- **Input sources** — YouTube imports and additional local upload options
|
|
||||||
- **Testing** — unit and integration test coverage, including FFmpeg-related functionality
|
|
||||||
- **API documentation** — comprehensive, auto-generated backend API docs
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ services:
|
|||||||
container_name: vod_frontend
|
container_name: vod_frontend
|
||||||
environment:
|
environment:
|
||||||
VITE_API_URL: ${BACKEND_URL}
|
VITE_API_URL: ${BACKEND_URL}
|
||||||
VITE_FRONTEND_URL: ${FRONTEND_URL}
|
|
||||||
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|||||||
@@ -5,32 +5,15 @@ type props = {
|
|||||||
dataSend: React.MouseEventHandler<HTMLButtonElement>;
|
dataSend: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
|
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
downloadable: boolean;
|
downloadable: boolean;
|
||||||
progress: number;
|
progress: number
|
||||||
uploading: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Spinner = () => (
|
const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props) => {
|
||||||
<svg
|
|
||||||
className="animate-spin h-4 w-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col gap-3"}>
|
<div className={"flex flex-col gap-3"}>
|
||||||
<BlueButton
|
<BlueButton
|
||||||
onClick={dataSend}
|
onClick={dataSend}>
|
||||||
disabled={uploading}
|
Export
|
||||||
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
|
||||||
>
|
|
||||||
{uploading && <Spinner />}
|
|
||||||
{uploading ? "Uploading..." : "Export"}
|
|
||||||
</BlueButton>
|
</BlueButton>
|
||||||
|
|
||||||
<div className="h-10 flex items-center">
|
<div className="h-10 flex items-center">
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ const ClipEdit = () => {
|
|||||||
});
|
});
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [downloadable, setDownloadable] = useState(false);
|
const [downloadable, setDownloadable] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,10 +59,7 @@ const ClipEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendData = async () => {
|
const sendData = async () => {
|
||||||
if (!localFile || uploading) return;
|
if (!localFile) return;
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
let jobId: string;
|
let jobId: string;
|
||||||
try {
|
try {
|
||||||
@@ -71,7 +67,6 @@ const ClipEdit = () => {
|
|||||||
setUploadedId(jobId);
|
setUploadedId(jobId);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
setUploading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,17 +81,14 @@ const ClipEdit = () => {
|
|||||||
if (job.state === 'FAILED') {
|
if (job.state === 'FAILED') {
|
||||||
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
setUploading(false);
|
|
||||||
} else if (job.isComplete) {
|
} else if (job.isComplete) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
setDownloadable(true);
|
setDownloadable(true);
|
||||||
setUploading(false);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
setError(`Failed to fetch progress: ${err.message}`);
|
setError(`Failed to fetch progress: ${err.message}`);
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
setUploading(false);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,7 +166,6 @@ const ClipEdit = () => {
|
|||||||
handleDownload={handleDownload}
|
handleDownload={handleDownload}
|
||||||
downloadable={downloadable}
|
downloadable={downloadable}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
uploading={uploading}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import flowbiteReact from "flowbite-react/plugin/vite";
|
import flowbiteReact from "flowbite-react/plugin/vite";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => { // change to function form
|
|
||||||
const env = loadEnv(mode, process.cwd(), '') // load all env vars
|
|
||||||
|
|
||||||
return {
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss(), flowbiteReact()],
|
plugins: [react(), tailwindcss(), flowbiteReact()],
|
||||||
preview: {
|
preview: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
@@ -16,8 +15,7 @@ export default defineConfig(({ mode }) => { // change to function form
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: true,
|
host: true,
|
||||||
origin: env.VITE_FRONTEND_URL || "http://0.0.0.0:5173", // use it here
|
origin: "http://0.0.0.0:5173",
|
||||||
allowedHosts: true,
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/v1': {
|
'/api/v1': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
@@ -26,5 +24,4 @@ export default defineConfig(({ mode }) => { // change to function form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
@@ -6,7 +6,6 @@ import com.ddf.vodsystem.entities.User;
|
|||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
import com.ddf.vodsystem.services.UserService;
|
import com.ddf.vodsystem.services.UserService;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseCookie;
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -42,15 +41,14 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
||||||
HttpServletRequest request,
|
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
String jwt = userService.login(token.token());
|
String jwt = userService.login(token.token());
|
||||||
|
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.maxAge(jwtExpiration / 1000)
|
.maxAge(jwtExpiration / 1000)
|
||||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
.sameSite("None")
|
||||||
.secure(request.isSecure())
|
.secure(true)
|
||||||
.path("/")
|
.path("/")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -62,13 +60,12 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
|
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response) {
|
||||||
HttpServletRequest request) {
|
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", "")
|
ResponseCookie cookie = ResponseCookie.from("token", "")
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.maxAge(0)
|
.maxAge(0)
|
||||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
.sameSite("None")
|
||||||
.secure(request.isSecure())
|
.secure(true)
|
||||||
.path("/")
|
.path("/")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import java.nio.file.Path;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -215,7 +214,7 @@ public class MediaService {
|
|||||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||||
})
|
})
|
||||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
@@ -75,9 +76,13 @@ public class StreamActionsService {
|
|||||||
progressTracker.markComplete();
|
progressTracker.markComplete();
|
||||||
|
|
||||||
return CompletableFuture.completedFuture(outputFile);
|
return CompletableFuture.completedFuture(outputFile);
|
||||||
} catch (Exception e) {
|
} catch (IOException e) {
|
||||||
logger.error("Failed to save section to '{}'", outputFile, e);
|
logger.error("Failed to save section to '{}'", outputFile, e);
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
logger.error("Failed to save section");
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return CompletableFuture.failedFuture(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
25
src/main/resources/db/data.sql
Normal file
25
src/main/resources/db/data.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
INSERT INTO users ( google_id, username, email, name)
|
||||||
|
VALUES
|
||||||
|
('google-uid-001', 'alice', 'alice@example.com', 'Alice Example'),
|
||||||
|
( 'google-uid-002', 'bob', 'bob@example.com', 'Bob Example'),
|
||||||
|
('google-uid-003', 'carol', 'carol@example.com', 'Carol Example'),
|
||||||
|
( 'google-uid-004', 'wizard42', 'gandalf@middle.earth', 'Gandalf the Grey'),
|
||||||
|
( 'google-uid-005', 'catnap', 'whiskers@meowmail.com', 'Sir Whiskers McFluff'),
|
||||||
|
( 'google-uid-006', 'robotron', 'bender@futurama.tv', 'Bender Rodriguez'),
|
||||||
|
('google-uid-007', 'unicorn', 'sparkle@rainbow.com', 'Princess Sparklehoof'),
|
||||||
|
( 'google-uid-008', 'pirate', 'blackbeard@seas.com', 'Edward Teach'),
|
||||||
|
( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'),
|
||||||
|
( 'google-uid-010', 'timey', 'docbrown@delorean.net', 'Dr. Emmett Brown');
|
||||||
|
|
||||||
|
INSERT INTO clips (user_id, title, description, width, height, fps, duration, file_size, video_path)
|
||||||
|
VALUES
|
||||||
|
(4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'),
|
||||||
|
(5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'),
|
||||||
|
(6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'),
|
||||||
|
(7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'),
|
||||||
|
( 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'),
|
||||||
|
( 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'),
|
||||||
|
( 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'),
|
||||||
|
( 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'),
|
||||||
|
( 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'),
|
||||||
|
( 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4');
|
||||||
Reference in New Issue
Block a user