Compare commits

...

10 Commits

Author SHA1 Message Date
fe097c8ee7 build: add comprehensive docker files for frontend & backend
Update docker-compose.yml to include new Dockerfiles
2026-02-22 21:38:43 +00:00
ab943be44e refactor(frontend): move media blob fetch function to endpoint file 2026-02-22 21:38:08 +00:00
0c5b93571b chore: add .env to gitignore 2026-02-22 21:37:07 +00:00
f2596e75f0 fix(api): incorrect local CORS url 2026-02-22 01:00:57 +00:00
7c95f87b40 chore: remove HTTPS module in frontend 2026-02-22 00:45:41 +00:00
Dylan De Faoite
83aa6e230d Update README to remove user management section
Removed user management plans from the README as these have been implemented
2026-01-08 11:09:58 +00:00
eed46cf266 Extract database clip saving into saveClip()
persistClip() had grown too large and handled multiple concerns.
This change moves the database save logic into saveClip(), making
persistClip() smaller and easier to refactor further.
2025-12-15 22:06:57 +00:00
65ec8cb29a MOVE clip creation method from ClipService to JobService
The original clip creation method in ClipService handled many different services, like metadata handling, callback methods and more. Moving it to a more suitable location in the JobService allows it to easily access the Job object and update it upon failure.
2025-12-15 21:23:01 +00:00
0f5fc76e55 ADD unified metadata validation to the MetadataService
Validation was happening in two places, in both EditService and in MetadataService doing different validations. This unifies them both into a singular method
2025-12-15 21:19:53 +00:00
2d0d168784 ADD information-level logging to EditService 2025-12-15 20:28:27 +00:00
13 changed files with 196 additions and 135 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
### Security ### ### Security ###
.env.local .env.local
.env.prod .env.prod
.env
node_modules node_modules
HELP.md HELP.md

View File

@@ -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) 6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
# Future Plans # Future Plans
- **User Management**
- Database integration with authentication and login
- Clip saving and management for authenticated users
- **Format Handling** - **Format Handling**
- Backend conversion of non-MP4 files via FFMPEG for broader format support - Backend conversion of non-MP4 files via FFMPEG for broader format support
- **Input Sources** - **Input Sources**

View File

@@ -1,9 +1,8 @@
version: '3.8'
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
container_name: my_postgres container_name: vod_postgres
environment: environment:
POSTGRES_USER: myuser POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword POSTGRES_PASSWORD: mypassword
@@ -13,6 +12,31 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - 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: volumes:
postgres_data: postgres_data:

9
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -1,7 +1,7 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {useParams} from "react-router-dom"; import {useParams} from "react-router-dom";
import type {Clip} from "../utils/types"; 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 Box from "../components/Box.tsx"
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts"; import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
@@ -13,28 +13,18 @@ const VideoPlayer = () => {
const [timeAgo, setTimeAgo] = useState<String>(""); const [timeAgo, setTimeAgo] = useState<String>("");
useEffect(() => { 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) { if (!id) {
setError("Clip ID is required."); setError("Clip ID is required.");
return; return;
} }
getVideoBlob(id)
.then((blob) => {
const url = URL.createObjectURL(blob);
setVideoUrl(url);
})
getClipById(id) getClipById(id)
.then((fetchedClip) => {setClip(fetchedClip)}) .then((fetchedClip) => {setClip(fetchedClip)})
.catch((err) => { .catch((err) => {

View File

@@ -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 isThumbnailAvailable = async (id: number): Promise<boolean> => {
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"}); const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"});
if (!response.ok) { if (!response.ok) {
@@ -260,5 +274,6 @@ export {
getUser, getUser,
getClips, getClips,
getClipById, getClipById,
getVideoBlob,
isThumbnailAvailable isThumbnailAvailable
}; };

View File

@@ -2,12 +2,11 @@ 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";
import mkcert from 'vite-plugin-mkcert'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss(), flowbiteReact(), mkcert()], plugins: [react(), tailwindcss(), flowbiteReact()],
preview: { preview: {
port: 5173, port: 5173,
strictPort: true, strictPort: true,

21
src/main/Dockerfile Normal file
View 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"]

View File

@@ -1,6 +1,5 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.ProgressTracker;
import com.ddf.vodsystem.dto.ClipOptions; import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.entities.*; 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.FFMPEGException;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.ClipRepository; 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.MetadataService;
import com.ddf.vodsystem.services.media.ThumbnailService; import com.ddf.vodsystem.services.media.ThumbnailService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -28,60 +26,22 @@ public class ClipService {
private final ClipRepository clipRepository; private final ClipRepository clipRepository;
private final DirectoryService directoryService; private final DirectoryService directoryService;
private final CompressionService compressionService;
private final MetadataService metadataService; private final MetadataService metadataService;
private final ThumbnailService thumbnailService; private final ThumbnailService thumbnailService;
private final UserService userService; private final UserService userService;
public ClipService(ClipRepository clipRepository, public ClipService(ClipRepository clipRepository,
DirectoryService directoryService, DirectoryService directoryService,
CompressionService compressionService,
MetadataService metadataService, MetadataService metadataService,
ThumbnailService thumbnailService, ThumbnailService thumbnailService,
UserService userService) { UserService userService) {
this.clipRepository = clipRepository; this.clipRepository = clipRepository;
this.directoryService = directoryService; this.directoryService = directoryService;
this.compressionService = compressionService;
this.metadataService = metadataService; this.metadataService = metadataService;
this.thumbnailService = thumbnailService; this.thumbnailService = thumbnailService;
this.userService = userService; 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. * 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()); 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, User user,
File tempFile, File tempFile,
String fileName) { String fileName) {
@@ -181,25 +168,14 @@ public class ClipService {
try { try {
thumbnailService.createThumbnail(clipFile, thumbnailFile, 0.0f); thumbnailService.createThumbnail(clipFile, thumbnailFile, 0.0f);
} catch (IOException | InterruptedException e) { } 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(); Thread.currentThread().interrupt();
} }
// Save clip to database clipMetadata.setTitle(clipOptions.getTitle());
Clip clip = new Clip(); clipMetadata.setDescription(clipOptions.getDescription());
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);
Clip clip = saveClip(clipMetadata, user, clipFile.getAbsolutePath(), thumbnailFile.getAbsolutePath());
logger.info("Clip created successfully with ID: {}", clip.getId()); logger.info("Clip created successfully with ID: {}", clip.getId());
} }
@@ -211,7 +187,7 @@ public class ClipService {
Files.deleteIfExists(clipFile.toPath()); Files.deleteIfExists(clipFile.toPath());
Files.deleteIfExists(thumbnailFile.toPath()); Files.deleteIfExists(thumbnailFile.toPath());
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(e); logger.error("Could not delete clip files for clip ID: {}", clip.getId());
} }
} }
} }

View File

@@ -3,69 +3,44 @@ package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.JobStatus; import com.ddf.vodsystem.dto.JobStatus;
import com.ddf.vodsystem.dto.ClipOptions; import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.Job; import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.services.media.MetadataService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class EditService { public class EditService {
private final JobService jobService; private final JobService jobService;
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(EditService.class); 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.jobService = jobService;
this.metadataService = metadataService;
} }
public void edit(String uuid, ClipOptions clipOptions) { public void edit(String uuid, ClipOptions clipOptions) {
Job job = jobService.getJob(uuid); Job job = jobService.getJob(uuid);
validateClipConfig(clipOptions); metadataService.validateMetadata(job.getInputClipOptions(), clipOptions);
job.setOutputClipOptions(clipOptions); job.setOutputClipOptions(clipOptions);
logger.info("Job {} - Updated clip config to {}", job.getUuid(), clipOptions);
} }
public void process(String uuid) { public void process(String uuid) {
Job job = jobService.getJob(uuid); Job job = jobService.getJob(uuid);
jobService.processJob(job); jobService.processJob(job);
logger.info("Job {} - Started processing", uuid);
} }
public void convert(String uuid) { public void convert(String uuid) {
Job job = jobService.getJob(uuid); Job job = jobService.getJob(uuid);
jobService.convertJob(job); jobService.convertJob(job);
logger.info("Job {} - Started converting", uuid);
} }
public JobStatus getStatus(String uuid) { public JobStatus getStatus(String uuid) {
Job job = jobService.getJob(uuid); Job job = jobService.getJob(uuid);
return job.getStatus(); 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");
}
}
} }

View File

@@ -4,11 +4,14 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import com.ddf.vodsystem.dto.Job; 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 com.ddf.vodsystem.services.media.RemuxService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -26,16 +29,23 @@ public class JobService {
private final ClipService clipService; private final ClipService clipService;
private final RemuxService remuxService; private final RemuxService remuxService;
private final DirectoryService directoryService; private final DirectoryService directoryService;
private final CompressionService compressionService;
private final UserService userService;
/** /**
* Constructs a JobService with the given CompressionService. * Constructs a JobService with the given CompressionService.
* @param clipService the compression service to use for processing jobs * @param clipService the compression service to use for processing jobs
*/ */
public JobService(ClipService clipService, public JobService(ClipService clipService,
RemuxService remuxService, DirectoryService directoryService) { RemuxService remuxService,
CompressionService compressionService,
DirectoryService directoryService,
UserService userService) {
this.clipService = clipService; this.clipService = clipService;
this.remuxService = remuxService; this.remuxService = remuxService;
this.directoryService = directoryService; this.directoryService = directoryService;
this.compressionService = compressionService;
this.userService = userService;
} }
/** /**
@@ -84,7 +94,14 @@ public class JobService {
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(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) { } catch (IOException | InterruptedException e) {
logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage()); logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage());
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
@@ -100,18 +117,25 @@ public class JobService {
job.getStatus().getProcess().reset(); job.getStatus().getProcess().reset();
try { try {
clipService.create( Optional<User> user = userService.getLoggedInUser();
job.getInputClipOptions(), compressionService.compress(job.getInputFile(), job.getOutputFile(), job.getOutputClipOptions(), job.getStatus().getProcess())
job.getOutputClipOptions(), .thenRun(() -> user.ifPresent(userVal ->
job.getInputFile(), clipService.persistClip(
job.getOutputFile(), job.getOutputClipOptions(),
job.getStatus().getProcess() userVal,
); job.getOutputFile(),
job.getInputFile().getName()
)
)).exceptionally(
ex -> {
job.getStatus().setFailed(true);
return null;
}
);
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
job.getStatus().setFailed(true);
logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage()); logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage());
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (FFMPEGException e) {
job.getStatus().setFailed(true);
} }
} }
} }

View File

@@ -51,14 +51,45 @@ public class MetadataService {
} }
} }
public void normalizeVideoMetadata(ClipOptions inputFileMetadata, ClipOptions outputFileMetadata) { public void validateMetadata(ClipOptions inputFileMetadata, ClipOptions outputFileMetadata) {
if (outputFileMetadata.getStartPoint() == null) { 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); outputFileMetadata.setStartPoint(0f);
} }
if (outputFileMetadata.getDuration() == null) { if (duration == null) {
outputFileMetadata.setDuration(inputFileMetadata.getDuration()); 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");
}
} }

View File

@@ -10,4 +10,4 @@ spring.sql.init.schema-locations=classpath:db/schema.sql
#spring.sql.init.data-locations=classpath:db/data.sql #spring.sql.init.data-locations=classpath:db/data.sql
# Frontend # Frontend
frontend.url=https://localhost:5173 frontend.url=http://localhost:5173