Merge pull request #8 from ThisBirchWood/3-clip-saving

Implemented Clip Saving and page to view existing clips
This commit is contained in:
Dylan De Faoite
2025-07-16 00:02:12 +02:00
committed by GitHub
40 changed files with 1177 additions and 425 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -5,6 +5,8 @@ import ClipUpload from './pages/ClipUpload';
import ClipEdit from './pages/ClipEdit';
import Home from './pages/Home';
import {useEffect} from "react";
import MyClips from './pages/MyClips';
import VideoPlayer from "./pages/VideoPlayer.tsx";
function App() {
@@ -19,6 +21,8 @@ function App() {
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/video/:id" element={<VideoPlayer />} />
</Route>
</Routes>
</Router>

View File

@@ -7,7 +7,7 @@ type prop = {
className?: string;
}
export default function ClipConfig({setMetadata, className}: prop) {
export default function ConfigBox({setMetadata, className}: prop) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
var vals = e.target.value.split(",");
setMetadata((prevState: VideoMetadata) => ({

View File

@@ -0,0 +1,41 @@
import clsx from "clsx";
import type {VideoMetadata} from "../../utils/types.ts";
import Selector from "../Selector.tsx";
type MetadataBoxProps = {
setMetadata: Function
className?: string;
}
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
return (
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
<h2 className={"text-3xl font-bold mb-4 col-span-2"}>Metadata Settings</h2>
<Selector label={"Title"}>
<input
type="text"
placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
title: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
/>
</Selector>
<Selector label={"Description"}>
<textarea
placeholder="Enter description"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
description: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2 pb-10 resize-none max-h"}
/>
</Selector>
</div>
)
}
export default MetadataBox;

View File

@@ -2,6 +2,7 @@ import { useEffect, useState} from "react";
import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react';
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts";
import { formatTime } from "../../utils/utils.ts";
type Props = {
@@ -10,20 +11,6 @@ type Props = {
className?: string;
};
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const padded = (n: number) => n.toString().padStart(2, '0');
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
}
export default function Playbar({ video, videoMetadata, className }: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100);

View File

@@ -0,0 +1,80 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import { useState } from "react";
type VideoCardProps = {
id: number,
title: string,
duration: number,
thumbnailPath: string | null,
videoPath: string,
createdAt: string,
className?: string
}
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const VideoCard = ({
id,
title,
duration,
thumbnailPath,
createdAt,
className
}: VideoCardProps) => {
const initialSrc = thumbnailPath && thumbnailPath.trim() !== "" ? thumbnailPath : fallbackThumbnail;
const [imgSrc, setImgSrc] = useState(initialSrc);
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
setTimeout(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)))
}, 1000);
return (
<Link to={"/video/" + id}>
<div className={clsx("flex flex-col", className)}>
<div className={"relative inline-block"}>
<img
src={imgSrc}
alt="Video Thumbnail"
onError={() => {
if (imgSrc !== fallbackThumbnail) {
setImgSrc(fallbackThumbnail);
}
}}
/>
<p className="
absolute
top-2
left-2
bg-black bg-opacity-60
text-white
px-2
py-1
rounded
pointer-events-none
text-sm
z-1
">
{formatTime(duration)}
</p>
</div>
<div className={"flex flex-col justify-between p-2"}>
<p>{title == "" ? "(No Title)" : title}</p>
<p
className={"text-gray-600 text-sm"}
>
{timeAgo}
</p>
</div>
</div>
</Link>
);
}
export default VideoCard;

View File

@@ -3,11 +3,12 @@ import { useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ClipConfig from "./../components/video/ClipConfig";
import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx";
import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints"
import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx";
const ClipEdit = () => {
const { id } = useParams();
@@ -17,6 +18,8 @@ const ClipEdit = () => {
const [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
// default values
title: "",
description: "",
startPoint: 0,
endPoint: 5,
width: 1280,
@@ -31,30 +34,32 @@ const ClipEdit = () => {
const sendData = async() => {
if (!id) return;
setDownloadable(false);
editFile(id, outputMetadata)
.then(() => {
const edited = await editFile(id, outputMetadata, setError);
processFile(id)
.catch((err: Error) => setError(`Failed to process file: ${err.message}`));
if (!edited) {
return;
})
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
const interval = setInterval(async() => await pollProgress(id, interval), 500);
}
const processed = await processFile(id, setError);
if (!processed) {
return;
}
const interval = setInterval(async () => {
const progress = await getProgress(id);
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress);
if (progress >= 1) {
clearInterval(interval);
clearInterval(intervalId);
setDownloadable(true);
console.log("Downloadable");
}
}, 500);
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
const handleDownload = async () => {
@@ -98,7 +103,10 @@ const ClipEdit = () => {
<Box className={"w-4/5 h-full m-auto"}>
<ClipConfig
<MetadataBox
setMetadata={setOutputMetadata}
/>
<ConfigBox
setMetadata={setOutputMetadata}
/>
</Box>

View File

@@ -10,13 +10,14 @@ const clipUpload = () => {
const [error, setError] = useState<null | string>(null);
const press = (() => {
if (file) {
uploadFile(file, setError)
.then(uuid => navigate(`/create/${uuid}`))
.catch(e => console.error(e));
} else {
if (!file) {
setError("Please choose a file");
return;
}
uploadFile(file)
.then(uuid => navigate(`/create/${uuid}`))
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
});
return (

View File

@@ -0,0 +1,36 @@
import VideoCard from "../components/video/VideoCard";
import {useEffect, useState} from "react";
import { getClips } from "../utils/endpoints";
import type { Clip } from "../utils/types";
const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null);
useEffect(() => {
getClips()
.then((data) => setClips(data))
.catch((err) => setError(err));
}, []);
return (
<div className={"flex flex-row"}>
{clips.map((clip) => (
<VideoCard
id={clip.id}
key={clip.videoPath}
title={clip.title}
duration={clip.duration}
thumbnailPath={clip.thumbnailPath}
videoPath={clip.videoPath}
createdAt={clip.createdAt}
className={"w-40 m-5"}
/>
))}
{error}
</div>
);
}
export default MyClips;

View File

@@ -0,0 +1,88 @@
import {useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import type {Clip} from "../utils/types";
import {getClipById} from "../utils/endpoints.ts";
import Box from "../components/Box.tsx"
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
const VideoPlayer = () => {
const { id } = useParams();
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [clip, setClip] = useState<Clip | null>(null);
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;
}
getClipById(id)
.then((fetchedClip) => {setClip(fetchedClip)})
.catch((err) => {
console.error("Error fetching clip:", err);
setError("Failed to load clip details. Please try again later.");
});
}, [id]);
// Update timeAgo live every second
useEffect(() => {
if (!clip || !clip.createdAt) return;
const update = () => {
const date = stringToDate(clip.createdAt);
setTimeAgo(dateToTimeAgo(date));
};
update(); // initial update
const interval = setInterval(update, 1000);
return () => clearInterval(interval); // cleanup
}, [clip]);
return (
<div className={"w-9/10 m-auto"}>
<video
className={"w-full h-full rounded-lg m-auto"}
controls
autoPlay
src={videoUrl}
onError={(e) => {
setError(e.currentTarget.error?.message || "An error occurred while playing the video.");
}}
>
Your browser does not support the video tag.
</video>
{error && <div className="text-red-500 mt-2">{error}</div>}
{!videoUrl && !error && <div className="text-gray-500 mt-2">Loading video...</div>}
<Box className={"p-2 m-2 flex flex-col"}>
<p className={"text-2xl font-bold text-gray-600"}>{clip?.title ? clip?.title : "(No Title)"}</p>
<p>{timeAgo}</p>
</Box>
</div>
);
};
export default VideoPlayer;

View File

@@ -1,13 +1,13 @@
import type {VideoMetadata, APIResponse, User} from "./types.ts";
import type {VideoMetadata, APIResponse, User, Clip} from "./types.ts";
/**
* Uploads a file to the backend.
* @param file - The file to upload.
*/
const uploadFile = async (file: File, setError: Function): Promise<string> => {
const uploadFile = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/v1/upload', {
method: 'POST',
body: formData,
@@ -16,22 +16,18 @@ const uploadFile = async (file: File, setError: Function): Promise<string> => {
const result: APIResponse = await response.json();
if (result.status == "error") {
setError(result.message);
throw new Error(`Failed to upload file: ${result.message}`);
}
return result.data.uuid;
} catch (error: unknown) {
throw new Error(`Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* Submits metadata changes to the backend.
* @param uuid - The UUID of the video file to edit.
* @param videoMetadata - The metadata to update.
*/
const editFile = async (
uuid: string,
videoMetadata: VideoMetadata,
setError: Function ): Promise<boolean> => {
const editFile = async (uuid: string, videoMetadata: VideoMetadata) => {
const formData = new URLSearchParams();
for (const [key, value] of Object.entries(videoMetadata)) {
@@ -40,7 +36,6 @@ const editFile = async (
}
}
try {
const response = await fetch(`/api/v1/edit/${uuid}`, {
method: 'POST',
headers: {
@@ -49,64 +44,65 @@ const editFile = async (
body: formData.toString(),
});
if (!response.ok) {
throw new Error(`Failed to edit file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
setError(result.message);
return false;
throw new Error(`Failed to edit file: ${result.message}`);
}
return true;
} catch (error: unknown) {
console.error('Error editing file:', error);
return false;
}
};
/**
* Triggers file processing.
* @param uuid - The UUID of the video file to process.
*/
const processFile = async (uuid: string, setError: Function): Promise<boolean> => {
try {
const processFile = async (uuid: string) => {
const response = await fetch(`/api/v1/process/${uuid}`);
const result: APIResponse = await response.json();
if (result.status === "error") {
setError(result.message);
return false;
if (!response.ok) {
throw new Error(`Failed to process file: ${response.status}`);
}
return response.ok;
} catch (error: unknown) {
console.error('Error processing file:', error);
return false;
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to process file: " + result.message);
}
};
/**
* Fetches the processing progress percentage.
* @param uuid - The UUID of the video file.
*/
const getProgress = async (uuid: string): Promise<number> => {
try {
const response = await fetch(`/api/v1/progress/${uuid}`);
if (!response.ok) {
console.error('Failed to fetch progress:', response.status);
return 0;
throw new Error(`Failed to fetch progress: ${response.status}`);
}
const result = await response.json();
return result.data?.progress ?? 0;
} catch (error: unknown) {
console.error('Error getting progress:', error);
return 0;
if (result.status === "error") {
throw new Error(`Failed to fetch progress: ${result.message}`);
}
if (!result.data || typeof result.data.progress !== 'number') {
throw new Error('Invalid progress data received');
}
return result.data.progress;
};
/**
* Fetches original metadata from the backend.
* @param uuid - The UUID of the video file.
*/
const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
try {
const response = await fetch(`/api/v1/metadata/original/${uuid}`);
if (!response.ok) {
@@ -114,38 +110,79 @@ const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
}
const result = await response.json();
return result.data;
} catch (error: unknown) {
console.error('Error fetching metadata:', error);
return {
startPoint: 0,
endPoint: 0,
fps: 0,
width: 0,
height: 0,
fileSize: 0,
};
if (result.status === "error") {
throw new Error(`Failed to fetch metadata: ${result.message}`);
}
return result.data;
};
/**
* Fetches the current user information. Returns null if not authenticated.
*/
const getUser = async (): Promise<null | User > => {
try {
const response = await fetch('/api/v1/auth/user', {credentials: "include",});
const result = await response.json();
return result.data;
} catch (error: unknown) {
console.error('Error fetching user:', error);
if (!response.ok) {
return null;
}
const result = await response.json();
if (result.status === "error") {
return null;
}
return result.data;
}
/**
* Fetches all clips for the current user.
*/
const getClips = async (): Promise<Clip[]> => {
const response = await fetch('/api/v1/clips/', { credentials: 'include' });
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
}
try {
const result: APIResponse = await response.json();
return result.data;
} catch {
throw new Error('Failed to parse response');
}
}
/**
* Fetches a clip by its ID.
* @param id
*/
const getClipById = async (id: string): Promise<Clip | null> => {
const response = await fetch(`/api/v1/clips/${id}`, {credentials: "include",});
if (!response.ok) {
throw new Error(`Failed to fetch clip: ${response.status}`);
}
try{
const result: APIResponse = await response.json();
return result.data;
} catch (error: unknown) {
throw new Error(`Failed to parse clip response:
${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
export {
uploadFile,
editFile,
processFile,
getProgress,
getMetadata,
getUser
getUser,
getClips,
getClipById
};

View File

@@ -1,4 +1,6 @@
type VideoMetadata = {
title: string,
description: string,
startPoint: number,
endPoint: number,
fps: number,
@@ -19,8 +21,22 @@ type User = {
profilePicture: string
}
type Clip = {
id: number,
title: string,
description: string,
duration: number,
thumbnailPath: string,
videoPath: string,
fps: number,
width: number,
height: number,
createdAt: string,
}
export type {
APIResponse,
VideoMetadata,
User
User,
Clip
}

View File

@@ -0,0 +1,77 @@
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const padded = (n: number) => n.toString().padStart(2, '0');
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
}
function stringToDate(dateString: string): Date {
const validIso = dateString.substring(0, 23);
const date = new Date(validIso);
if (isNaN(date.getTime())) {
throw new Error("Invalid date string");
}
return date;
}
function dateToTimeAgo(date: Date): string {
const now = new Date();
const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000);
if (secondsAgo < 60) {
return `${secondsAgo} seconds ago`;
} else if (secondsAgo < 3600) {
const minutes = Math.floor(secondsAgo / 60);
if (minutes === 1) {
return "1 minute ago";
}
return `${minutes} minutes ago`;
} else if (secondsAgo < 86400) {
const hours = Math.floor(secondsAgo / 3600);
if (hours === 1) {
return "1 hour ago";
}
return `${hours} hours ago`;
} else if (secondsAgo < 2592000) {
const days = Math.floor(secondsAgo / 86400);
if (days === 1) {
return "1 day ago";
}
return `${days} days ago`;
} else if (secondsAgo < 31536000) {
const months = Math.floor(secondsAgo / 2592000);
if (months === 1) {
return "1 month ago";
}
return `${months} months ago`;
} else {
const years = Math.floor(secondsAgo / 31536000);
if (years === 1) {
return "1 year ago";
}
return `${years} years ago`;
}
}
export {
formatTime,
stringToDate,
dateToTimeAgo
}

View File

@@ -2,8 +2,10 @@ package com.ddf.vodsystem;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class VodSystemApplication {
public static void main(String[] args) {

View File

@@ -29,6 +29,7 @@ public class SecurityConfig {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/download/clip/**").authenticated()
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll()
.requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll()
.requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**").permitAll()

View File

@@ -1,6 +1,7 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.entities.APIResponse;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -14,21 +15,17 @@ import java.util.Map;
@RestController
@RequestMapping("/api/v1/auth/")
public class AuthController {
@GetMapping("/user")
public ResponseEntity<APIResponse<Map<String, Object>>> user(@AuthenticationPrincipal OAuth2User principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).
body(new APIResponse<>(
"error",
"User not authenticated",
null
));
throw new NotAuthenticated("User is not authenticated");
}
if (principal.getAttribute("email") == null
if (
principal.getAttribute("email") == null
|| principal.getAttribute("name") == null
|| principal.getAttribute("picture") == null) {
|| principal.getAttribute("picture") == null)
{
return ResponseEntity.status(HttpStatus.BAD_REQUEST).
body(new APIResponse<>(
"error",

View File

@@ -0,0 +1,53 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.entities.APIResponse;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.ClipService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@Controller
@RequestMapping("/api/v1/clips")
public class ClipController {
private final ClipService clipService;
public ClipController(ClipService clipService) {
this.clipService = clipService;
}
@GetMapping("/")
public ResponseEntity<APIResponse<List<Clip>>> getClips(@AuthenticationPrincipal OAuth2User principal) {
if (principal == null) {
throw new NotAuthenticated("User is not authenticated");
}
List<Clip> clips = clipService.getClipsByUser();
return ResponseEntity.ok(
new APIResponse<>("success", "Clips retrieved successfully", clips)
);
}
@GetMapping("/{id}")
public ResponseEntity<APIResponse<Clip>> getClipById(@AuthenticationPrincipal OAuth2User principal, @PathVariable Long id) {
if (principal == null) {
throw new NotAuthenticated("User is not authenticated");
}
Clip clip = clipService.getClipById(id);
if (clip == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(
new APIResponse<>("success", "Clip retrieved successfully", clip)
);
}
}

View File

@@ -7,6 +7,8 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -49,4 +51,18 @@ public class DownloadController {
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/clip/{id}")
public ResponseEntity<Resource> downloadClip(@AuthenticationPrincipal OAuth2User principal, @PathVariable Long id) {
Resource resource = downloadService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + resource.getFilename() + "\"")
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
}

View File

@@ -6,6 +6,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import com.ddf.vodsystem.entities.APIResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

View File

@@ -4,6 +4,7 @@ import com.ddf.vodsystem.entities.APIResponse;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -22,50 +23,57 @@ public class GlobalExceptionHandler {
@ExceptionHandler({ MultipartException.class })
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
logger.error("MultipartException: {}", ex.getMessage(), ex);
logger.error("MultipartException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Multipart request error: " + ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler({ MissingServletRequestPartException.class })
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
logger.error("MissingServletRequestPartException: {}", ex.getMessage(), ex);
logger.error("MissingServletRequestPartException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Required file part is missing.", null);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage(), ex);
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Unsupported media type: expected multipart/form-data.", null);
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
logger.error("IllegalArgumentException: {}", ex.getMessage(), ex);
logger.error("IllegalArgumentException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler(JobNotFound.class)
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
logger.error("JobNotFound: {}", ex.getMessage(), ex);
logger.error("JobNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(JobNotFinished.class)
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
logger.error("JobNotFinished: {}", ex.getMessage(), ex);
logger.error("JobNotFinished: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
}
@ExceptionHandler(FFMPEGException.class)
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
logger.error("FFMPEGException: {}", ex.getMessage(), ex);
logger.error("FFMPEGException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "FFMPEG Error: Please upload a valid file", null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
@ExceptionHandler(NotAuthenticated.class)
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
logger.error("NotAuthenticated: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "User is not authenticated", null);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
}

View File

@@ -1,8 +1,9 @@
package com.ddf.vodsystem.entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Data;
import lombok.ToString;
import java.time.LocalDateTime;
@@ -16,7 +17,7 @@ public class Clip {
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@ToString.Exclude
@JsonIgnore
@JoinColumn(name = "user_id", nullable = false)
private User user;
@@ -36,13 +37,13 @@ public class Clip {
private Integer height;
@Column(name = "fps", nullable = false)
private Integer fps;
private Float fps;
@Column(name = "duration", nullable = false)
private Integer duration;
private Float duration;
@Column(name = "file_size", nullable = false)
private Long fileSize;
private Float fileSize;
@Column(name = "video_path", nullable = false, length = 255)
private String videoPath;

View File

@@ -1,6 +1,9 @@
package com.ddf.vodsystem.entities;
import java.io.File;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.security.core.context.SecurityContext;
import lombok.Data;
@@ -14,9 +17,12 @@ public class Job {
private VideoMetadata inputVideoMetadata;
private VideoMetadata outputVideoMetadata = new VideoMetadata();
// security
private SecurityContext securityContext;
// job status
private JobStatus status = JobStatus.NOT_READY;
private Float progress = 0.0f;
private AtomicReference<Float> progress = new AtomicReference<>(0f);
public Job(String uuid, File inputFile, File outputFile, VideoMetadata inputVideoMetadata) {
this.uuid = uuid;

View File

@@ -4,6 +4,8 @@ import lombok.Data;
@Data
public class VideoMetadata {
private String title;
private String description;
private Float startPoint;
private Float endPoint;
private Float fps;

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.exceptions;
public class NotAuthenticated extends RuntimeException {
public NotAuthenticated(String message) {
super(message);
}
}

View File

@@ -1,9 +1,17 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.entities.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ClipRepository extends JpaRepository<Clip, Long> {
@Query("SELECT c FROM Clip c WHERE c.user = ?1")
List<Clip> findByUser(User user);
}

View File

@@ -0,0 +1,36 @@
package com.ddf.vodsystem.security;
import com.ddf.vodsystem.entities.User;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.Map;
@Getter
public class CustomOAuth2User implements OAuth2User {
private final OAuth2User oauth2User;
private final User user;
public CustomOAuth2User(OAuth2User oauth2User, User user) {
this.oauth2User = oauth2User;
this.user = user;
}
@Override
public Map<String, Object> getAttributes() {
return oauth2User.getAttributes();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return oauth2User.getAuthorities();
}
@Override
public String getName() {
return oauth2User.getName();
}
}

View File

@@ -4,16 +4,16 @@ import com.ddf.vodsystem.repositories.UserRepository;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@@ -22,26 +22,26 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService {
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
public CustomOAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
var delegate = new DefaultOAuth2UserService();
var oAuth2User = delegate.loadUser(userRequest);
String email = oAuth2User.getAttribute("email");
String name = oAuth2User.getAttribute("name");
String googleId = oAuth2User.getAttribute("sub");
Optional<User> existingUser = userRepository.findByGoogleId(googleId);
User user = userRepository.findByGoogleId(googleId)
.orElseGet(() -> {
User newUser = new User();
newUser.setEmail(email);
newUser.setName(name);
newUser.setGoogleId(googleId);
newUser.setUsername(email);
newUser.setRole(0);
newUser.setCreatedAt(LocalDateTime.now());
return userRepository.save(newUser);
});
if (existingUser.isEmpty()) {
User user = new User();
user.setEmail(email);
user.setName(name);
user.setGoogleId(googleId);
user.setUsername(email);
user.setRole(0);
user.setCreatedAt(LocalDateTime.now());
userRepository.save(user);
}
return oAuth2User;
return new CustomOAuth2User(oAuth2User, user);
}
}

View File

@@ -0,0 +1,107 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.*;
import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import com.ddf.vodsystem.repositories.ClipRepository;
import com.ddf.vodsystem.security.CustomOAuth2User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Service
public class ClipService {
private static final Logger logger = LoggerFactory.getLogger(ClipService.class);
private final ClipRepository clipRepository;
private final MetadataService metadataService;
private final DirectoryService directoryService;
private final FfmpegService ffmpegService;
public ClipService(ClipRepository clipRepository,
MetadataService metadataService,
DirectoryService directoryService,
FfmpegService ffmpegService) {
this.clipRepository = clipRepository;
this.metadataService = metadataService;
this.directoryService = directoryService;
this.ffmpegService = ffmpegService;
}
/**
* Runs the FFMPEG command to create a video clip based on the provided job.
* Updates the job status and progress as the command executes.
* This method validates the input and output video metadata,
* Updates the job VideoMetadata with the output file size,
*
* @param job the job containing input and output video metadata
* @throws IOException if an I/O error occurs during command execution
* @throws InterruptedException if the thread is interrupted while waiting for the process to finish
*
*/
public void run(Job job) throws IOException, InterruptedException {
metadataService.normalizeVideoMetadata(job.getInputVideoMetadata(), job.getOutputVideoMetadata());
ffmpegService.runWithProgress(job.getInputFile(), job.getOutputFile(), job.getOutputVideoMetadata(), job.getProgress());
Float fileSize = metadataService.getFileSize(job.getOutputFile());
job.getOutputVideoMetadata().setFileSize(fileSize);
User user = getUser();
if (user != null) {
persistClip(job.getOutputVideoMetadata(), user, job);
}
job.setStatus(JobStatus.FINISHED);
logger.info("FFMPEG finished successfully for job: {}", job.getUuid());
}
public List<Clip> getClipsByUser() {
User user = getUser();
if (user == null) {
logger.warn("No authenticated user found");
return List.of();
}
return clipRepository.findByUser(user);
}
private User getUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof CustomOAuth2User oAuth2user) {
return oAuth2user.getUser();
}
return null;
}
private void persistClip(VideoMetadata videoMetadata, User user, Job job) {
// Move clip from temp to output directory
String fileExtension = directoryService.getFileExtension(job.getOutputFile().getAbsolutePath());
File outputFile = directoryService.getOutputFile(job.getUuid(), fileExtension);
directoryService.copyFile(job.getOutputFile(), outputFile);
// Save clip to database
Clip clip = new Clip();
clip.setUser(user);
clip.setTitle(videoMetadata.getTitle() != null ? videoMetadata.getTitle() : "Untitled Clip");
clip.setDescription(videoMetadata.getDescription());
clip.setCreatedAt(LocalDateTime.now());
clip.setWidth(videoMetadata.getWidth());
clip.setHeight(videoMetadata.getHeight());
clip.setFps(videoMetadata.getFps());
clip.setDuration(videoMetadata.getEndPoint() - videoMetadata.getStartPoint());
clip.setFileSize(videoMetadata.getFileSize());
clip.setVideoPath(outputFile.getPath());
clipRepository.save(clip);
}
public Clip getClipById(Long id) {
return clipRepository.findById(id).orElse(null);
}
}

View File

@@ -1,144 +0,0 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.VideoMetadata;
import com.ddf.vodsystem.entities.JobStatus;
import com.ddf.vodsystem.entities.Job;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class CompressionService {
private static final Logger logger = LoggerFactory.getLogger(CompressionService.class);
private static final float AUDIO_RATIO = 0.15f;
private static final float MAX_AUDIO_BITRATE = 128f;
private static final float BITRATE_MULTIPLIER = 0.9f;
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
private void validateVideoMetadata(VideoMetadata inputFileMetadata, VideoMetadata outputFileMetadata) {
if (outputFileMetadata.getStartPoint() == null) {
outputFileMetadata.setStartPoint(0f);
}
if (outputFileMetadata.getEndPoint() == null) {
outputFileMetadata.setEndPoint(inputFileMetadata.getEndPoint());
}
}
private void buildFilters(ArrayList<String> command, Float fps, Integer width, Integer height) {
List<String> filters = new ArrayList<>();
if (fps != null) {
filters.add("fps=" + fps);
}
if (!(width == null && height == null)) {
String w = (width != null) ? width.toString() : "-1";
String h = (height != null) ? height.toString() : "-1";
filters.add("scale=" + w + ":" + h);
}
if (!filters.isEmpty()) {
command.add("-vf");
command.add(String.join(",", filters));
}
}
private void buildBitrate(ArrayList<String> command, Float length, Float fileSize) {
float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER;
float audioBitrate = bitrate * AUDIO_RATIO;
float videoBitrate;
if (audioBitrate > MAX_AUDIO_BITRATE) {
audioBitrate = MAX_AUDIO_BITRATE;
videoBitrate = bitrate - MAX_AUDIO_BITRATE;
} else {
videoBitrate = bitrate * (1 - AUDIO_RATIO);
}
command.add("-b:v");
command.add(videoBitrate + "k");
command.add("-b:a");
command.add(audioBitrate + "k");
}
private void buildInputs(ArrayList<String> command, File inputFile, Float startPoint, Float length) {
command.add("-ss");
command.add(startPoint.toString());
command.add("-i");
command.add(inputFile.getAbsolutePath());
command.add("-t");
command.add(Float.toString(length));
}
private ProcessBuilder buildCommand(File inputFile, File outputFile, VideoMetadata videoMetadata) {
ArrayList<String> command = new ArrayList<>();
command.add("ffmpeg");
command.add("-progress");
command.add("pipe:1");
command.add("-y");
Float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint();
buildInputs(command, inputFile, videoMetadata.getStartPoint(), length);
buildFilters(command, videoMetadata.getFps(), videoMetadata.getWidth(), videoMetadata.getHeight());
if (videoMetadata.getFileSize() != null) {
buildBitrate(command, length, videoMetadata.getFileSize());
}
// Output file
command.add(outputFile.getAbsolutePath());
logger.info("Running command: {}", command);
return new ProcessBuilder(command);
}
public void run(Job job) throws IOException, InterruptedException {
logger.info("FFMPEG starting...");
validateVideoMetadata(job.getInputVideoMetadata(), job.getOutputVideoMetadata());
ProcessBuilder pb = buildCommand(job.getInputFile(), job.getOutputFile(), job.getOutputVideoMetadata());
Process process = pb.start();
job.setStatus(JobStatus.RUNNING);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
float length = job.getOutputVideoMetadata().getEndPoint() - job.getOutputVideoMetadata().getStartPoint();
String line;
while ((line = reader.readLine()) != null) {
logger.debug(line);
Matcher matcher = timePattern.matcher(line);
if (matcher.find()) {
Float progress = Long.parseLong(matcher.group(1))/(length*1000000);
job.setProgress(progress);
}
}
if (process.waitFor() != 0) {
job.setStatus(JobStatus.FAILED);
throw new FFMPEGException("FFMPEG process failed");
}
job.setStatus(JobStatus.FINISHED);
logger.info("FFMPEG finished");
}
}

View File

@@ -0,0 +1,136 @@
package com.ddf.vodsystem.services;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import org.slf4j.Logger;
@Service
public class DirectoryService {
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(DirectoryService.class);
@Value("${storage.outputs}")
private String outputDir;
@Value("${storage.temp.inputs}")
private String tempInputsDir;
@Value("${storage.temp.outputs}")
private String tempOutputsDir;
private static final long TEMP_DIR_TIMELIMIT = 3 * 60 * 60 * (long) 1000; // 3 hours
private static final long TEMP_DIR_CLEANUP_RATE = 30 * 60 * (long) 1000; // 30 minutes
public File getTempInputFile(String id, String extension) {
String dir = tempInputsDir + File.separator + id + (extension.isEmpty() ? "" : "." + extension);
return new File(dir);
}
public File getTempOutputFile(String id, String extension) {
String dir = tempOutputsDir + File.separator + id + (extension.isEmpty() ? "" : "." + extension);
return new File(dir);
}
public File getOutputFile(String id, String extension) {
if (id == null || id.length() < 2) {
throw new IllegalArgumentException("ID must be at least 2 characters long");
}
// Create subdirectories from first 2 characters of the ID
String shard1 = id.substring(0, 2);
String shard2 = id.substring(2);
String dir = outputDir +
File.separator +
shard1 +
File.separator +
shard2 +
(extension.isEmpty() ? "" : "." + extension);
return new File(dir);
}
public void saveAtDir(File file, MultipartFile multipartFile) {
try {
createDirectory(file.getAbsolutePath());
Path filePath = Paths.get(file.getAbsolutePath());
Files.copy(multipartFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
public void copyFile(File source, File target) {
Path sourcePath = Paths.get(source.getAbsolutePath());
Path destPath = Paths.get(target.getAbsolutePath());
try {
Files.createDirectories(destPath.getParent());
Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING);
logger.info("Copied file from {} to {}", sourcePath, destPath);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
public String getFileExtension(String filePath) {
Path path = Paths.get(filePath);
String fileName = path.getFileName().toString();
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) {
return ""; // No extension
}
return fileName.substring(dotIndex + 1);
}
private void createDirectory(String dir) throws IOException {
// Create the directory if it doesn't exist
Path outputPath = Paths.get(dir);
if (Files.notExists(outputPath)) {
Files.createDirectories(outputPath);
logger.info("Created directory: {}", outputPath);
}
}
private void cleanUpDirectory(String dir) throws IOException {
File file = new File(dir);
File[] files = file.listFiles();
if (files == null) {
logger.warn("No files found in directory: {}", dir);
return;
}
for (File f : files){
if (f.isFile() && f.lastModified() < (System.currentTimeMillis() - TEMP_DIR_TIMELIMIT)) {
Files.delete(f.toPath());
}
}
}
@PostConstruct
public void createDirectoriesIfNotExist() throws IOException {
createDirectory(tempInputsDir);
createDirectory(tempOutputsDir);
createDirectory(outputDir);
}
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
public void cleanTempDirectories() throws IOException {
cleanUpDirectory(tempInputsDir);
cleanUpDirectory(tempOutputsDir);
}
}

View File

@@ -1,9 +1,11 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.entities.JobStatus;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.repositories.ClipRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
@@ -15,10 +17,12 @@ import java.io.File;
public class DownloadService {
private final JobService jobService;
private final ClipRepository clipRepository;
@Autowired
public DownloadService(JobService jobService) {
public DownloadService(JobService jobService, ClipRepository clipRepository) {
this.jobService = jobService;
this.clipRepository = clipRepository;
}
public Resource downloadInput(String uuid) {
@@ -46,4 +50,19 @@ public class DownloadService {
File file = job.getOutputFile();
return new FileSystemResource(file);
}
public Resource downloadClip(Clip clip) {
String path = clip.getVideoPath();
File file = new File(path);
if (!file.exists()) {
throw new JobNotFound("Clip file not found");
}
return new FileSystemResource(file);
}
public Resource downloadClip(Long id) {
Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
return downloadClip(clip);
}
}

View File

@@ -1,13 +1,12 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.VideoMetadata;
import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.entities.JobStatus;
import com.ddf.vodsystem.entities.*;
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);
public EditService(JobService jobService) {
this.jobService = jobService;
@@ -30,7 +29,7 @@ public class EditService {
return 1f;
}
return job.getProgress();
return job.getProgress().get();
}
private void validateClipConfig(VideoMetadata videoMetadata) {
@@ -69,4 +68,5 @@ public class EditService {
throw new IllegalArgumentException("FPS cannot be less than 1");
}
}
}

View File

@@ -0,0 +1,148 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.VideoMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class FfmpegService {
private static final Logger logger = LoggerFactory.getLogger(FfmpegService.class);
private static final float AUDIO_RATIO = 0.15f;
private static final float MAX_AUDIO_BITRATE = 128f;
private static final float BITRATE_MULTIPLIER = 0.9f;
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
public void runWithProgress(File inputFile, File outputFile, VideoMetadata videoMetadata, AtomicReference<Float> progress) throws IOException, InterruptedException {
logger.info("Starting FFMPEG process");
List<String> command = buildCommand(inputFile, outputFile, videoMetadata);
String strCommand = String.join(" ", command);
logger.info("FFMPEG command: {}", strCommand);
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
logger.info("FFMPEG process started with PID: {}", process.pid());
updateJobProgress(process, progress, videoMetadata.getEndPoint() - videoMetadata.getStartPoint());
process.waitFor();
logger.info("FFMPEG process completed successfully");
}
public void run(File inputFile, File outputFile, VideoMetadata videoMetadata) throws IOException, InterruptedException {
runWithProgress(inputFile, outputFile, videoMetadata, new AtomicReference<>(0f));
}
private void updateJobProgress(Process process, AtomicReference<Float> progress, Float length) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
logger.debug(line);
Matcher matcher = timePattern.matcher(line);
if (matcher.find()) {
Float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
progress.set(timeInMs/length);
}
}
}
private List<String> buildFilters(Float fps, Integer width, Integer height) {
List<String> command = new ArrayList<>();
command.add("-vf");
List<String> filters = new ArrayList<>();
if (fps != null) {
logger.info("Frame rate set to {}", fps);
filters.add("fps=" + fps);
}
if (!(width == null && height == null)) {
logger.info("Scaling video to width: {}, height: {}", width, height);
String w = (width != null) ? width.toString() : "-1";
String h = (height != null) ? height.toString() : "-1";
filters.add("scale=" + w + ":" + h);
}
logger.info("Adding video filters");
command.add(String.join(",", filters));
return command;
}
private List<String> buildBitrate(Float length, Float fileSize) {
List<String> command = new ArrayList<>();
float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER;
float audioBitrate = bitrate * AUDIO_RATIO;
float videoBitrate;
if (audioBitrate > MAX_AUDIO_BITRATE) {
audioBitrate = MAX_AUDIO_BITRATE;
videoBitrate = bitrate - MAX_AUDIO_BITRATE;
} else {
videoBitrate = bitrate * (1 - AUDIO_RATIO);
}
command.add("-b:v");
command.add(videoBitrate + "k");
command.add("-b:a");
command.add(audioBitrate + "k");
return command;
}
private List<String> buildInputs(File inputFile, Float startPoint, Float length) {
List<String> command = new ArrayList<>();
command.add("-ss");
command.add(startPoint.toString());
command.add("-i");
command.add(inputFile.getAbsolutePath());
command.add("-t");
command.add(Float.toString(length));
return command;
}
private List<String> buildCommand(File inputFile, File outputFile, VideoMetadata videoMetadata) {
List<String> command = new ArrayList<>();
command.add("ffmpeg");
command.add("-progress");
command.add("pipe:1");
command.add("-y");
Float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint();
command.addAll(buildInputs(inputFile, length, length));
if (videoMetadata.getFps() != null || videoMetadata.getWidth() != null || videoMetadata.getHeight() != null) {
command.addAll(buildFilters(videoMetadata.getFps(), videoMetadata.getWidth(), videoMetadata.getHeight()));
}
if (videoMetadata.getFileSize() != null) {
command.addAll(buildBitrate(length, videoMetadata.getFileSize()));
}
// Output file
command.add(outputFile.getAbsolutePath());
return command;
}
}

View File

@@ -7,6 +7,8 @@ import java.util.concurrent.LinkedBlockingQueue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import com.ddf.vodsystem.entities.Job;
@@ -24,14 +26,14 @@ public class JobService {
private static final Logger logger = LoggerFactory.getLogger(JobService.class);
private final ConcurrentHashMap<String, Job> jobs = new ConcurrentHashMap<>();
private final BlockingQueue<Job> jobQueue = new LinkedBlockingQueue<>();
private final CompressionService compressionService;
private final ClipService clipService;
/**
* Constructs a JobService with the given CompressionService.
* @param compressionService the compression service to use for processing jobs
* @param clipService the compression service to use for processing jobs
*/
public JobService(CompressionService compressionService) {
this.compressionService = compressionService;
public JobService(ClipService clipService) {
this.clipService = clipService;
}
/**
@@ -65,7 +67,9 @@ public class JobService {
*/
public void jobReady(String uuid) {
Job job = getJob(uuid);
job.setProgress(0f);
SecurityContext context = SecurityContextHolder.getContext();
job.setSecurityContext(context);
logger.info("Job ready: {}", job.getUuid());
job.setStatus(JobStatus.PENDING);
@@ -77,11 +81,21 @@ public class JobService {
* @param job the job to process
*/
private void processJob(Job job) {
SecurityContext previousContext = SecurityContextHolder.getContext(); // optional, for restoring later
try {
compressionService.run(job);
if (job.getSecurityContext() != null) {
SecurityContextHolder.setContext(job.getSecurityContext());
}
clipService.run(job);
} catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Error while running job {}", job.getUuid(), e);
} finally {
// 🔄 Restore previous context to avoid leaking across jobs
SecurityContextHolder.setContext(previousContext);
}
}
@@ -98,6 +112,7 @@ public class JobService {
Job job = jobQueue.take(); // Blocks until a job is available
logger.info("Starting job {}", job.getUuid());
job.setStatus(JobStatus.RUNNING);
processJob(job);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();

View File

@@ -1,6 +1,5 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.entities.VideoMetadata;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -16,13 +15,7 @@ import java.io.InputStreamReader;
@Service
public class MetadataService {
private static Logger logger = LoggerFactory.getLogger(MetadataService.class);
private final JobService jobService;
public MetadataService(JobService jobService) {
this.jobService = jobService;
}
private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
public VideoMetadata getVideoMetadata(File file) {
logger.info("Getting metadata for file {}", file.getAbsolutePath());
@@ -50,14 +43,34 @@ public class MetadataService {
}
}
public VideoMetadata getInputFileMetadata(String uuid) {
Job job = jobService.getJob(uuid);
return getVideoMetadata(job.getInputFile());
public Float getFileSize(File file) {
logger.info("Getting file size for {}", file.getAbsolutePath());
VideoMetadata metadata = getVideoMetadata(file);
if (metadata.getFileSize() == null) {
throw new FFMPEGException("File size not found");
}
public VideoMetadata getOutputFileMetadata(String uuid) {
Job job = jobService.getJob(uuid);
return getVideoMetadata(job.getOutputFile());
return metadata.getFileSize();
}
public Float getVideoDuration(File file) {
logger.info("Getting video duration for {}", file.getAbsolutePath());
VideoMetadata metadata = getVideoMetadata(file);
if (metadata.getEndPoint() == null) {
throw new FFMPEGException("Video duration not found");
}
return metadata.getEndPoint();
}
public void normalizeVideoMetadata(VideoMetadata inputFileMetadata, VideoMetadata outputFileMetadata) {
if (outputFileMetadata.getStartPoint() == null) {
outputFileMetadata.setStartPoint(0f);
}
if (outputFileMetadata.getEndPoint() == null) {
outputFileMetadata.setEndPoint(inputFileMetadata.getEndPoint());
}
}
private JsonNode readStandardOutput(Process process) throws IOException{

View File

@@ -2,19 +2,12 @@ package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.entities.VideoMetadata;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Base64;
import java.util.UUID;
@@ -25,35 +18,30 @@ import org.slf4j.LoggerFactory;
public class UploadService {
private static final Logger logger = LoggerFactory.getLogger(UploadService.class);
@Value("${temp.vod.storage}")
private String inputDir;
@Value("${temp.vod.output}")
private String outputDir;
private final JobService jobService;
private final MetadataService metadataService;
private final DirectoryService directoryService;
@Autowired
public UploadService(JobService jobService, MetadataService metadataService) {
public UploadService(JobService jobService,
MetadataService metadataService,
DirectoryService directoryService) {
this.jobService = jobService;
this.metadataService = metadataService;
this.directoryService = directoryService;
}
public String upload(MultipartFile file) {
// generate uuid, filename
String uuid = generateShortUUID();
String extension = getFileExtension(file.getOriginalFilename());
String filename = uuid + (extension.isEmpty() ? "" : "." + extension);
String extension = directoryService.getFileExtension(file.getOriginalFilename());
Path inputPath = Paths.get(inputDir, filename);
File inputFile = inputPath.toFile();
Path outputPath = Paths.get(outputDir, filename);
File outputFile = outputPath.toFile();
moveToFile(file, inputFile);
File inputFile = directoryService.getTempInputFile(uuid, extension);
File outputFile = directoryService.getTempOutputFile(uuid, extension);
directoryService.saveAtDir(inputFile, file);
// add job
logger.info("Uploaded file and creating job with UUID: {}", uuid);
VideoMetadata videoMetadata = metadataService.getVideoMetadata(inputFile);
Job job = new Job(uuid, inputFile, outputFile, videoMetadata);
jobService.add(job);
@@ -61,15 +49,6 @@ public class UploadService {
return uuid;
}
private void moveToFile(MultipartFile inputFile, File outputFile) {
try {
Path filePath = Paths.get(outputFile.getAbsolutePath());
Files.copy(inputFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
private static String generateShortUUID() {
UUID uuid = UUID.randomUUID();
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
@@ -78,39 +57,4 @@ public class UploadService {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array());
}
private static String getFileExtension(String filePath) {
Path path = Paths.get(filePath);
String fileName = path.getFileName().toString();
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) {
return ""; // No extension
}
return fileName.substring(dotIndex + 1);
}
private void createDirectories() throws IOException {
// Create INPUT_DIR if it doesn't exist
Path inputDirPath = Paths.get(inputDir);
if (Files.notExists(inputDirPath)) {
Files.createDirectories(inputDirPath);
logger.info("Created directory: {}", inputDir);
}
// Create OUTPUT_DIR if it doesn't exist
Path outputDirPath = Paths.get(outputDir);
if (Files.notExists(outputDirPath)) {
Files.createDirectories(outputDirPath);
logger.info("Created directory: {}", outputDir);
}
}
@PostConstruct
public void init() {
try {
createDirectories();
} catch (IOException e) {
logger.error("Failed to create directories: " + e.getMessage(), e);
}
}
}

View File

@@ -7,7 +7,7 @@ spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.sql.init.mode=always
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
# Security
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}

View File

@@ -4,8 +4,9 @@ spring.profiles.active=local
# VODs
spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB
temp.vod.storage=videos/inputs/
temp.vod.output=videos/outputs/
storage.temp.inputs=videos/inputs/
storage.temp.outputs=videos/outputs/
storage.outputs=videos/clips/
# Logging
logging.level.org.springframework.web=DEBUG
## Server Configuration
server.servlet.session.timeout=30m

View File

@@ -11,15 +11,15 @@ VALUES
( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'),
( 'google-uid-010', 'timey', 'docbrown@delorean.net', 'Dr. Emmett Brown');
INSERT INTO clips (id, user_id, title, description, width, height, fps, duration, file_size, video_path)
INSERT INTO clips (user_id, title, description, width, height, fps, duration, file_size, video_path)
VALUES
(1, 4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'),
(2, 5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'),
(3, 6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'),
(4, 7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'),
(5, 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'),
(6, 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'),
(7, 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'),
(8, 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'),
(9, 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'),
(10, 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4');
(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');

View File

@@ -1,5 +1,5 @@
DROP TABLE IF EXISTS clips;
DROP TABLE IF EXISTS users;
-- DROP TABLE IF EXISTS clips;
-- DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
@@ -19,9 +19,9 @@ CREATE TABLE IF NOT EXISTS clips (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
fps INTEGER NOT NULL,
duration INTEGER NOT NULL,
file_size BIGINT NOT NULL,
fps FLOAT NOT NULL,
duration FLOAT NOT NULL,
file_size FLOAT NOT NULL,
video_path VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);