Compare commits
10 Commits
decf2703bd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fe097c8ee7 | |||
| ab943be44e | |||
| 0c5b93571b | |||
| f2596e75f0 | |||
| 7c95f87b40 | |||
|
|
83aa6e230d | ||
| eed46cf266 | |||
| 65ec8cb29a | |||
| 0f5fc76e55 | |||
| 2d0d168784 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
### Security ###
|
||||
.env.local
|
||||
.env.prod
|
||||
.env
|
||||
|
||||
node_modules
|
||||
HELP.md
|
||||
|
||||
@@ -23,10 +23,6 @@ This VoD system began as a small project back in my 5th year of secondary school
|
||||
6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
|
||||
|
||||
# Future Plans
|
||||
|
||||
- **User Management**
|
||||
- Database integration with authentication and login
|
||||
- Clip saving and management for authenticated users
|
||||
- **Format Handling**
|
||||
- Backend conversion of non-MP4 files via FFMPEG for broader format support
|
||||
- **Input Sources**
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: my_postgres
|
||||
container_name: vod_postgres
|
||||
environment:
|
||||
POSTGRES_USER: myuser
|
||||
POSTGRES_PASSWORD: mypassword
|
||||
@@ -13,6 +12,31 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: src/main/Dockerfile
|
||||
container_name: vod_backend
|
||||
depends_on:
|
||||
- postgres
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: vod_frontend
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
9
frontend/Dockerfile
Normal file
9
frontend/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {useParams} from "react-router-dom";
|
||||
import type {Clip} from "../utils/types";
|
||||
import {getClipById} from "../utils/endpoints.ts";
|
||||
import {getClipById, getVideoBlob } from "../utils/endpoints.ts";
|
||||
import Box from "../components/Box.tsx"
|
||||
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
|
||||
|
||||
@@ -13,28 +13,18 @@ const VideoPlayer = () => {
|
||||
const [timeAgo, setTimeAgo] = useState<String>("");
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch the video URL from the server
|
||||
fetch(`/api/v1/download/clip/${id}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load video");
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
setVideoUrl(url);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error fetching video:", err);
|
||||
setError("Failed to load video. Please try again later.");
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
setError("Clip ID is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
getVideoBlob(id)
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
setVideoUrl(url);
|
||||
})
|
||||
|
||||
|
||||
getClipById(id)
|
||||
.then((fetchedClip) => {setClip(fetchedClip)})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -239,6 +239,20 @@ const getClipById = async (id: string): Promise<Clip | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoBlob = async(id: string): Promise<Blob> => {
|
||||
const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch video: ${id}: ${response.status}`)
|
||||
}
|
||||
|
||||
try {
|
||||
return response.blob();
|
||||
} catch {
|
||||
throw new Error(`Failed to convert Clip Return Object to blob`);
|
||||
}
|
||||
}
|
||||
|
||||
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
||||
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"});
|
||||
if (!response.ok) {
|
||||
@@ -260,5 +274,6 @@ export {
|
||||
getUser,
|
||||
getClips,
|
||||
getClipById,
|
||||
getVideoBlob,
|
||||
isThumbnailAvailable
|
||||
};
|
||||
@@ -2,12 +2,11 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import flowbiteReact from "flowbite-react/plugin/vite";
|
||||
import mkcert from 'vite-plugin-mkcert'
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), flowbiteReact(), mkcert()],
|
||||
plugins: [react(), tailwindcss(), flowbiteReact()],
|
||||
preview: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
|
||||
21
src/main/Dockerfile
Normal file
21
src/main/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# Build stage
|
||||
FROM maven:3.9.6-eclipse-temurin-21 AS builder
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
COPY src ./src
|
||||
RUN mvn clean package -DskipTests
|
||||
|
||||
|
||||
# Run stage
|
||||
FROM eclipse-temurin:21-jdk
|
||||
|
||||
# dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ffmpeg && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.ddf.vodsystem.services;
|
||||
|
||||
import com.ddf.vodsystem.dto.ProgressTracker;
|
||||
import com.ddf.vodsystem.dto.ClipOptions;
|
||||
import com.ddf.vodsystem.entities.*;
|
||||
|
||||
@@ -15,7 +14,6 @@ import java.util.concurrent.ExecutionException;
|
||||
import com.ddf.vodsystem.exceptions.FFMPEGException;
|
||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||
import com.ddf.vodsystem.repositories.ClipRepository;
|
||||
import com.ddf.vodsystem.services.media.CompressionService;
|
||||
import com.ddf.vodsystem.services.media.MetadataService;
|
||||
import com.ddf.vodsystem.services.media.ThumbnailService;
|
||||
import org.slf4j.Logger;
|
||||
@@ -28,60 +26,22 @@ public class ClipService {
|
||||
|
||||
private final ClipRepository clipRepository;
|
||||
private final DirectoryService directoryService;
|
||||
private final CompressionService compressionService;
|
||||
private final MetadataService metadataService;
|
||||
private final ThumbnailService thumbnailService;
|
||||
private final UserService userService;
|
||||
|
||||
public ClipService(ClipRepository clipRepository,
|
||||
DirectoryService directoryService,
|
||||
CompressionService compressionService,
|
||||
MetadataService metadataService,
|
||||
ThumbnailService thumbnailService,
|
||||
UserService userService) {
|
||||
this.clipRepository = clipRepository;
|
||||
this.directoryService = directoryService;
|
||||
this.compressionService = compressionService;
|
||||
this.metadataService = metadataService;
|
||||
this.thumbnailService = thumbnailService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the clip creation process.
|
||||
* This method normalizes the input metadata, compresses the video file,
|
||||
* updates the output metadata with the file size, and saves the clip
|
||||
* to the database if the user is authenticated.
|
||||
*
|
||||
* @param inputMetadata The metadata of the input video file.
|
||||
* @param outputMetadata The metadata for the output video file.
|
||||
* @param inputFile The input video file to be processed.
|
||||
* @param outputFile The output file where the processed video will be saved.
|
||||
* @param progress A tracker to monitor the progress of the video processing.
|
||||
* @throws IOException if an I/O error occurs during file processing.
|
||||
* @throws InterruptedException if the thread is interrupted during processing.
|
||||
*/
|
||||
public void create(ClipOptions inputMetadata,
|
||||
ClipOptions outputMetadata,
|
||||
File inputFile,
|
||||
File outputFile,
|
||||
ProgressTracker progress)
|
||||
throws IOException, InterruptedException {
|
||||
|
||||
Optional<User> user = userService.getLoggedInUser();
|
||||
metadataService.normalizeVideoMetadata(inputMetadata, outputMetadata);
|
||||
compressionService.compress(inputFile, outputFile, outputMetadata, progress)
|
||||
.thenRun(() -> user.ifPresent(value ->
|
||||
persistClip(
|
||||
outputMetadata,
|
||||
value,
|
||||
outputFile,
|
||||
inputFile.getName()
|
||||
))).exceptionally(ex -> {
|
||||
throw new FFMPEGException("FFMPEG Compression failed: " + ex.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all clips associated with the currently logged-in user.
|
||||
*
|
||||
@@ -161,7 +121,34 @@ public class ClipService {
|
||||
return user.get().getId().equals(clip.getUser().getId());
|
||||
}
|
||||
|
||||
private void persistClip(ClipOptions clipOptions,
|
||||
/**
|
||||
* Persists a clip to the database
|
||||
* @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description
|
||||
* @param user User to save the clip to
|
||||
* @param videoPath Path of the clip
|
||||
* @param thumbnailPath Path of the thumbnail
|
||||
* @return Clip object saved to the database
|
||||
*/
|
||||
public Clip saveClip(ClipOptions options,
|
||||
User user,
|
||||
String videoPath,
|
||||
String thumbnailPath) {
|
||||
Clip clip = new Clip();
|
||||
clip.setUser(user);
|
||||
clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip");
|
||||
clip.setDescription(options.getDescription() != null ? options.getDescription() : "");
|
||||
clip.setCreatedAt(LocalDateTime.now());
|
||||
clip.setWidth(options.getWidth());
|
||||
clip.setHeight(options.getHeight());
|
||||
clip.setFps(options.getFps());
|
||||
clip.setDuration(options.getDuration() - options.getStartPoint());
|
||||
clip.setFileSize(options.getFileSize());
|
||||
clip.setVideoPath(videoPath);
|
||||
clip.setThumbnailPath(thumbnailPath);
|
||||
return clipRepository.save(clip);
|
||||
}
|
||||
|
||||
public void persistClip(ClipOptions clipOptions,
|
||||
User user,
|
||||
File tempFile,
|
||||
String fileName) {
|
||||
@@ -181,25 +168,14 @@ public class ClipService {
|
||||
try {
|
||||
thumbnailService.createThumbnail(clipFile, thumbnailFile, 0.0f);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
logger.error("Error generating thumbnail for clip: {}", e.getMessage());
|
||||
logger.error("Error generating thumbnail for user: {}, {}", user.getId(), e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
// Save clip to database
|
||||
Clip clip = new Clip();
|
||||
clip.setUser(user);
|
||||
clip.setTitle(clipOptions.getTitle() != null ? clipOptions.getTitle() : "Untitled Clip");
|
||||
clip.setDescription(clipOptions.getDescription() != null ? clipOptions.getDescription() : "");
|
||||
clip.setCreatedAt(LocalDateTime.now());
|
||||
clip.setWidth(clipMetadata.getWidth());
|
||||
clip.setHeight(clipMetadata.getHeight());
|
||||
clip.setFps(clipMetadata.getFps());
|
||||
clip.setDuration(clipMetadata.getDuration() - clipMetadata.getStartPoint());
|
||||
clip.setFileSize(clipMetadata.getFileSize());
|
||||
clip.setVideoPath(clipFile.getPath());
|
||||
clip.setThumbnailPath(thumbnailFile.getPath());
|
||||
clipRepository.save(clip);
|
||||
clipMetadata.setTitle(clipOptions.getTitle());
|
||||
clipMetadata.setDescription(clipOptions.getDescription());
|
||||
|
||||
Clip clip = saveClip(clipMetadata, user, clipFile.getAbsolutePath(), thumbnailFile.getAbsolutePath());
|
||||
logger.info("Clip created successfully with ID: {}", clip.getId());
|
||||
}
|
||||
|
||||
@@ -211,7 +187,7 @@ public class ClipService {
|
||||
Files.deleteIfExists(clipFile.toPath());
|
||||
Files.deleteIfExists(thumbnailFile.toPath());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
logger.error("Could not delete clip files for clip ID: {}", clip.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,69 +3,44 @@ package com.ddf.vodsystem.services;
|
||||
import com.ddf.vodsystem.dto.JobStatus;
|
||||
import com.ddf.vodsystem.dto.ClipOptions;
|
||||
import com.ddf.vodsystem.dto.Job;
|
||||
import com.ddf.vodsystem.services.media.MetadataService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EditService {
|
||||
private final JobService jobService;
|
||||
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(EditService.class);
|
||||
private final MetadataService metadataService;
|
||||
|
||||
public EditService(JobService jobService) {
|
||||
public EditService(JobService jobService, MetadataService metadataService) {
|
||||
this.jobService = jobService;
|
||||
this.metadataService = metadataService;
|
||||
}
|
||||
|
||||
public void edit(String uuid, ClipOptions clipOptions) {
|
||||
Job job = jobService.getJob(uuid);
|
||||
validateClipConfig(clipOptions);
|
||||
metadataService.validateMetadata(job.getInputClipOptions(), clipOptions);
|
||||
job.setOutputClipOptions(clipOptions);
|
||||
|
||||
logger.info("Job {} - Updated clip config to {}", job.getUuid(), clipOptions);
|
||||
}
|
||||
|
||||
public void process(String uuid) {
|
||||
Job job = jobService.getJob(uuid);
|
||||
jobService.processJob(job);
|
||||
|
||||
logger.info("Job {} - Started processing", uuid);
|
||||
}
|
||||
|
||||
public void convert(String uuid) {
|
||||
Job job = jobService.getJob(uuid);
|
||||
jobService.convertJob(job);
|
||||
|
||||
logger.info("Job {} - Started converting", uuid);
|
||||
}
|
||||
|
||||
public JobStatus getStatus(String uuid) {
|
||||
Job job = jobService.getJob(uuid);
|
||||
return job.getStatus();
|
||||
}
|
||||
|
||||
private void validateClipConfig(ClipOptions clipOptions) {
|
||||
Float start = clipOptions.getStartPoint();
|
||||
Float duration = clipOptions.getDuration();
|
||||
Float fileSize = clipOptions.getFileSize();
|
||||
Integer width = clipOptions.getWidth();
|
||||
Integer height = clipOptions.getHeight();
|
||||
Float fps = clipOptions.getFps();
|
||||
|
||||
if (start != null && start < 0) {
|
||||
throw new IllegalArgumentException("Start point cannot be negative");
|
||||
}
|
||||
|
||||
if (duration != null && duration < 0) {
|
||||
throw new IllegalArgumentException("Duration cannot be negative");
|
||||
}
|
||||
|
||||
if (fileSize != null && fileSize < 100) {
|
||||
throw new IllegalArgumentException("File size cannot be less than 100kb");
|
||||
}
|
||||
|
||||
if (width != null && width < 1) {
|
||||
throw new IllegalArgumentException("Width cannot be less than 1");
|
||||
}
|
||||
|
||||
if (height != null && height < 1) {
|
||||
throw new IllegalArgumentException("Height cannot be less than 1");
|
||||
}
|
||||
|
||||
if (fps != null && fps < 1) {
|
||||
throw new IllegalArgumentException("FPS cannot be less than 1");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.ddf.vodsystem.dto.Job;
|
||||
import com.ddf.vodsystem.exceptions.FFMPEGException;
|
||||
import com.ddf.vodsystem.entities.User;
|
||||
import com.ddf.vodsystem.services.media.CompressionService;
|
||||
import com.ddf.vodsystem.services.media.RemuxService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -26,16 +29,23 @@ public class JobService {
|
||||
private final ClipService clipService;
|
||||
private final RemuxService remuxService;
|
||||
private final DirectoryService directoryService;
|
||||
private final CompressionService compressionService;
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* Constructs a JobService with the given CompressionService.
|
||||
* @param clipService the compression service to use for processing jobs
|
||||
*/
|
||||
public JobService(ClipService clipService,
|
||||
RemuxService remuxService, DirectoryService directoryService) {
|
||||
RemuxService remuxService,
|
||||
CompressionService compressionService,
|
||||
DirectoryService directoryService,
|
||||
UserService userService) {
|
||||
this.clipService = clipService;
|
||||
this.remuxService = remuxService;
|
||||
this.directoryService = directoryService;
|
||||
this.compressionService = compressionService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +94,14 @@ public class JobService {
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}).whenComplete((ignored, throwable) -> {
|
||||
if (throwable != null) {
|
||||
logger.error("Remux failed for jobId={}", job.getUuid(), throwable);
|
||||
} else {
|
||||
logger.info("Remux completed for jobId={}", job.getUuid());
|
||||
}
|
||||
});
|
||||
|
||||
} catch (IOException | InterruptedException e) {
|
||||
logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
@@ -100,18 +117,25 @@ public class JobService {
|
||||
job.getStatus().getProcess().reset();
|
||||
|
||||
try {
|
||||
clipService.create(
|
||||
job.getInputClipOptions(),
|
||||
Optional<User> user = userService.getLoggedInUser();
|
||||
compressionService.compress(job.getInputFile(), job.getOutputFile(), job.getOutputClipOptions(), job.getStatus().getProcess())
|
||||
.thenRun(() -> user.ifPresent(userVal ->
|
||||
clipService.persistClip(
|
||||
job.getOutputClipOptions(),
|
||||
job.getInputFile(),
|
||||
userVal,
|
||||
job.getOutputFile(),
|
||||
job.getStatus().getProcess()
|
||||
job.getInputFile().getName()
|
||||
)
|
||||
)).exceptionally(
|
||||
ex -> {
|
||||
job.getStatus().setFailed(true);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
job.getStatus().setFailed(true);
|
||||
logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (FFMPEGException e) {
|
||||
job.getStatus().setFailed(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,14 +51,45 @@ public class MetadataService {
|
||||
}
|
||||
}
|
||||
|
||||
public void normalizeVideoMetadata(ClipOptions inputFileMetadata, ClipOptions outputFileMetadata) {
|
||||
if (outputFileMetadata.getStartPoint() == null) {
|
||||
public void validateMetadata(ClipOptions inputFileMetadata, ClipOptions outputFileMetadata) {
|
||||
Float start = outputFileMetadata.getStartPoint();
|
||||
Float duration = outputFileMetadata.getDuration();
|
||||
Float fileSize = outputFileMetadata.getFileSize();
|
||||
Integer width = outputFileMetadata.getWidth();
|
||||
Integer height = outputFileMetadata.getHeight();
|
||||
Float fps = outputFileMetadata.getFps();
|
||||
|
||||
if (start == null) {
|
||||
outputFileMetadata.setStartPoint(0f);
|
||||
}
|
||||
|
||||
if (outputFileMetadata.getDuration() == null) {
|
||||
if (duration == null) {
|
||||
outputFileMetadata.setDuration(inputFileMetadata.getDuration());
|
||||
}
|
||||
|
||||
if (start != null && start < 0) {
|
||||
throw new IllegalArgumentException("Start point cannot be negative");
|
||||
}
|
||||
|
||||
if (duration != null && duration < 0) {
|
||||
throw new IllegalArgumentException("Duration cannot be negative");
|
||||
}
|
||||
|
||||
if (fileSize != null && fileSize < 100) {
|
||||
throw new IllegalArgumentException("File size cannot be less than 100kb");
|
||||
}
|
||||
|
||||
if (width != null && width < 1) {
|
||||
throw new IllegalArgumentException("Width cannot be less than 1");
|
||||
}
|
||||
|
||||
if (height != null && height < 1) {
|
||||
throw new IllegalArgumentException("Height cannot be less than 1");
|
||||
}
|
||||
|
||||
if (fps != null && fps < 1) {
|
||||
throw new IllegalArgumentException("FPS cannot be less than 1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ spring.sql.init.schema-locations=classpath:db/schema.sql
|
||||
#spring.sql.init.data-locations=classpath:db/data.sql
|
||||
|
||||
# Frontend
|
||||
frontend.url=https://localhost:5173
|
||||
frontend.url=http://localhost:5173
|
||||
|
||||
Reference in New Issue
Block a user